From f147f88d53e3931e6c29785862430fbb866f02b5 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 14:55:00 +0530 Subject: [PATCH 01/13] refactor(external): return an exit code from executeExternalCli The hosted runner needs the numeric result to return through runHostedCli, and process.exitCode is not observable in the hosted test harness. No behavior change on the local path. --- src/cli.ts | 2 +- src/external.test.ts | 43 +++++++++++++++++++++++++++++++++++++------ src/external.ts | 15 +++++---------- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b87eaadd..d10957e0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2284,7 +2284,7 @@ cli({ return process.argv.slice(idx + 1); })(); try { - executeExternalCli(name, args, externalClis); + process.exitCode = executeExternalCli(name, args, externalClis); } catch (err) { console.error(`Error: ${getErrorMessage(err)}`); process.exitCode = EXIT_CODES.GENERIC_ERROR; diff --git a/src/external.test.ts b/src/external.test.ts index 0f926b2c..ab031f8c 100644 --- a/src/external.test.ts +++ b/src/external.test.ts @@ -24,6 +24,7 @@ vi.mock('node:os', async () => { import { spawnSync } from 'node:child_process'; import { executeExternalCli, formatExternalCliLabel, installExternalCli, parseCommand, type ExternalCliConfig } from './external.js'; +import { EXIT_CODES } from './errors.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -151,29 +152,59 @@ describe('executeExternalCli passthrough', () => { .mockReturnValueOnce({ error: einval, status: null, signal: null } as unknown as ReturnType) .mockReturnValueOnce({ status: 0, signal: null } as unknown as ReturnType); - executeExternalCli('tg', ['send', 'hello world', '--to', 'a"b'], [cli]); + const code = executeExternalCli('tg', ['send', 'hello world', '--to', 'a"b'], [cli]); expect(spawnMock).toHaveBeenCalledTimes(2); expect(spawnMock).toHaveBeenNthCalledWith(1, 'tg', ['send', 'hello world', '--to', 'a"b'], { stdio: 'inherit' }); expect(spawnMock).toHaveBeenNthCalledWith(2, 'tg send "hello world" --to "a""b"', { stdio: 'inherit', shell: true }); - expect(process.exitCode).toBe(0); + expect(code).toBe(0); }); it('does not retry through the shell on non-Windows platforms', () => { const einval = Object.assign(new Error('spawnSync tg EINVAL'), { code: 'EINVAL' }); spawnMock.mockReturnValueOnce({ error: einval, status: null, signal: null } as unknown as ReturnType); - executeExternalCli('tg', [], [cli]); + const code = executeExternalCli('tg', [], [cli]); expect(spawnMock).toHaveBeenCalledTimes(1); - expect(process.exitCode).toBe(1); + expect(code).toBe(1); }); it('reports a non-zero exit code when the child dies from a signal', () => { spawnMock.mockReturnValueOnce({ status: null, signal: 'SIGKILL' } as unknown as ReturnType); - executeExternalCli('tg', [], [cli]); + const code = executeExternalCli('tg', [], [cli]); - expect(process.exitCode).toBe(1); + expect(code).toBe(1); + }); +}); + +describe('executeExternalCli', () => { + const spawnMock = vi.mocked(spawnSync); + const nodeBinary: ExternalCliConfig = { + name: 'fake-node', + binary: process.execPath, + description: 'Node itself, used as a guaranteed-present binary', + }; + + beforeEach(() => { + spawnMock.mockReset(); + }); + + it('returns the child exit code on success', () => { + spawnMock.mockReturnValueOnce({ status: 0, signal: null } as unknown as ReturnType); + const code = executeExternalCli('fake-node', ['-e', 'process.exit(0)'], [nodeBinary]); + expect(code).toBe(EXIT_CODES.SUCCESS); + }); + + it('returns the child exit code on failure', () => { + spawnMock.mockReturnValueOnce({ status: 3, signal: null } as unknown as ReturnType); + const code = executeExternalCli('fake-node', ['-e', 'process.exit(3)'], [nodeBinary]); + expect(code).toBe(3); + }); + + it('throws when the name is not in the registry', () => { + expect(() => executeExternalCli('absent', [], [nodeBinary])) + .toThrowError("External CLI 'absent' not found in registry."); }); }); diff --git a/src/external.ts b/src/external.ts index 9f95d7bc..4983c748 100644 --- a/src/external.ts +++ b/src/external.ts @@ -179,7 +179,7 @@ export function installExternalCli(cli: ExternalCliConfig): boolean { } } -export function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): void { +export function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): number { const configs = preloaded ?? loadExternalClis(); const cli = configs.find((c) => c.name === name); if (!cli) { @@ -191,8 +191,7 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext // 2. Try to auto install const success = installExternalCli(cli); if (!success) { - process.exitCode = EXIT_CODES.SERVICE_UNAVAIL; - return; + return EXIT_CODES.SERVICE_UNAVAIL; } } @@ -200,18 +199,14 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext const result = spawnPassthrough(cli.binary, args); if (result.error) { log.error(`Failed to execute '${cli.binary}': ${result.error.message}`); - process.exitCode = EXIT_CODES.GENERIC_ERROR; - return; + return EXIT_CODES.GENERIC_ERROR; } if (result.signal) { - process.exitCode = EXIT_CODES.GENERIC_ERROR; - return; + return EXIT_CODES.GENERIC_ERROR; } - if (result.status !== null) { - process.exitCode = result.status; - } + return result.status ?? EXIT_CODES.SUCCESS; } function quoteForCmdShell(token: string): string { From 4e672275b457d93bdde33a32306e518a89845cc3 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 14:59:35 +0530 Subject: [PATCH 02/13] feat(hosted): execute registered external CLIs in hosted mode Externals are local binaries, not adapters: registry lookup, PATH check, spawn. Nothing is sent to Cloud. Previously webcmd gh pr list returned plugin-install guidance in hosted mode, which fixes nothing. Resolved after the manifest so a hosted site wins over an external of the same name, matching local precedence, and before the root-options parse so --version still forwards to the external. --- src/hosted/external.test.ts | 132 ++++++++++++++++++++++++++++++++++++ src/hosted/runner.ts | 27 ++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/hosted/external.test.ts diff --git a/src/hosted/external.test.ts b/src/hosted/external.test.ts new file mode 100644 index 00000000..c64964c8 --- /dev/null +++ b/src/hosted/external.test.ts @@ -0,0 +1,132 @@ +import { Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { makeHostedConfig } from './config.js'; +import { runHostedCli } from './runner.js'; +import type { ExternalCliConfig } from '../external.js'; +import { PKG_VERSION } from '../version.js'; + +function sink(): { stream: Writable; text: () => string } { + let data = ''; + return { + stream: new Writable({ + write(chunk, _encoding, callback) { + data += String(chunk); + callback(); + }, + }), + text: () => data, + }; +} + +const manifest = { + userId: 'user_demo', + metadata: { + contractSchemaVersion: 1, + sessionProtocolVersion: 1, + webcmdPackageVersion: PKG_VERSION, + generatedAt: '2026-08-27T00:00:00.000Z', + }, + commands: [{ + site: 'github', + name: 'whoami', + command: 'github/whoami', + description: 'Show GitHub identity', + access: 'read', + strategy: 'PUBLIC', + browser: false, + args: [], + columns: ['username'], + }], +}; + +function manifestResponse(): Response { + return new Response(JSON.stringify({ ok: true, manifest }), { status: 200 }); +} + +const registry: ExternalCliConfig[] = [ + { name: 'gh', binary: 'gh', description: 'GitHub CLI' }, + { name: 'github', binary: 'gh-shadow', description: 'Shadows a hosted site name' }, +]; + +type RunFn = (name: string, args: string[], configs: ExternalCliConfig[]) => number; + +function harness(run: ReturnType>) { + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(async () => manifestResponse()); + return { + stdout, + stderr, + fetchImpl, + opts: { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + externals: { list: () => registry, run }, + }, + }; +} + +describe('hosted external CLI execution', () => { + it('spawns a registered external and returns its exit code', async () => { + const run = vi.fn(() => 3); + const h = harness(run); + + const result = await runHostedCli(['gh', 'pr', 'list', '--limit', '5'], h.opts); + + expect(run).toHaveBeenCalledWith('gh', ['pr', 'list', '--limit', '5'], registry); + expect(result).toEqual({ handled: true, exitCode: 3 }); + expect(h.stderr.text()).toBe(''); + }); + + it('forwards --version to the external instead of printing the webcmd version', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['gh', '--version'], h.opts); + + expect(run).toHaveBeenCalledWith('gh', ['--version'], registry); + expect(h.stdout.text()).toBe(''); + expect(result).toEqual({ handled: true, exitCode: 0 }); + }); + + it('sends nothing but the manifest request to Cloud', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + await runHostedCli(['gh', 'pr', 'list'], h.opts); + + expect(h.fetchImpl).toHaveBeenCalledTimes(1); + expect(String(h.fetchImpl.mock.calls[0]![0])).toBe('https://api.example.com/v1/manifest'); + }); + + it('lets a hosted site win over an external of the same name', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + await runHostedCli(['github', 'whoami'], { + ...h.opts, + fetchImpl: async (url: RequestInfo | URL) => String(url).endsWith('/v1/manifest') + ? manifestResponse() + : new Response(JSON.stringify({ + ok: true, + result: [], + execution: { id: 'exec_1', command: 'github/whoami', status: 'succeeded' }, + }), { status: 200 }), + }); + + expect(run).not.toHaveBeenCalled(); + }); + + it('still reports an unknown site when the name is neither', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['nonesuch', 'thing'], h.opts); + + expect(run).not.toHaveBeenCalled(); + expect(result.exitCode).toBe(2); + expect(h.stderr.text()).toContain('Site "nonesuch" is not installed.'); + }); +}); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 19c58c44..7a7bc88b 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -35,6 +35,7 @@ import { BrowserRunError } from '../browser/run/types.js'; import { CLI_COMMAND } from '../brand.js'; import { formatPluginSearchEmptyCopy, presentPluginSearch } from '../plugin-search-presentation.js'; import { missingPluginGuidance } from '../discovery.js'; +import { executeExternalCli, loadExternalClis, type ExternalCliConfig } from '../external.js'; import { webFetchCommand } from '../fetch/command.js'; import { runHostedArtifactDownload } from './artifact-download.js'; import { HostedClient, HostedClientError, resolveWorkspace } from './client.js'; @@ -97,6 +98,11 @@ export interface HostedRunnerOptions { files?: VirtualFileMap; /** When set, every file write lands here instead of the filesystem. */ outputs?: VirtualOutputSink; + /** Injection seam for the local external-CLI registry. Defaults to the real registry. */ + externals?: { + list(): ExternalCliConfig[]; + run(name: string, args: string[], configs: ExternalCliConfig[]): number; + }; } interface HostedDispatchIo { @@ -118,6 +124,11 @@ interface TrustedCommandResolution { accessClass: 'read' | 'write'; } +/** Carries an external CLI's own exit code out of dispatch. Not an error. */ +class ExternalExitSignal { + constructor(readonly exitCode: number) {} +} + class CommanderCompatibleError extends Error { constructor( readonly output: string, @@ -191,10 +202,14 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { opts.enableServerWebFetch === true, opts.signal, opts.onTrustedCommandResolution, + opts.externals ?? { list: loadExternalClis, run: executeExternalCli }, ); return { handled: true, exitCode: EXIT_CODES.SUCCESS }; } catch (caught) { if (caught instanceof StreamWriteError) throw caught; + if (caught instanceof ExternalExitSignal) { + return { handled: true, exitCode: caught.exitCode }; + } const err = opts.signal?.aborted ? new InterruptedError() : caught; if (err instanceof BrowserSessionArgvError) { await writeToStream(stderr, `error: ${err.message}\n`); @@ -253,6 +268,10 @@ async function dispatchHosted( enableServerWebFetch = false, signal?: AbortSignal, onResolvedCommand?: (resolution: TrustedCommandResolution) => void, + externals: { + list(): ExternalCliConfig[]; + run(name: string, args: string[], configs: ExternalCliConfig[]): number; + } = { list: loadExternalClis, run: executeExternalCli }, ): Promise { const normalized = parseHostedRootCommandSurface(argv); if (normalized.kind === 'help') { @@ -495,6 +514,14 @@ async function dispatchHosted( const commandName = args[1]; const siteExists = manifest.commands.some(command => command.site === site); if (!siteExists) { + // Externals are local binaries, not adapters: registry lookup, PATH check, + // spawn. Nothing reaches Cloud. This runs before parseUnknownSiteRootOptions + // so `webcmd gh --version` forwards --version to gh, matching the local + // passThroughOptions() behavior instead of printing the webcmd version. + const externalConfigs = externals.list(); + if (externalConfigs.some(config => config.name === site)) { + throw new ExternalExitSignal(externals.run(site, args.slice(1), externalConfigs)); + } const unknownRoot = parseUnknownSiteRootOptions(args, normalized.literal); if (unknownRoot.version) { await writeToStream(stdout, `${PKG_VERSION}\n`); From b1b19b1d3d59d4c1e1aa554ae898072b014f2e2d Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 15:47:00 +0530 Subject: [PATCH 03/13] docs: changelog for hosted external CLI passthrough Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f558430b..5aca544d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Added + +- Hosted mode executes registered external CLIs (`webcmd gh pr list`) on the caller's machine. Nothing is sent to Cloud, and a hosted site always wins over an external of the same name. + ## [0.7.7](https://github.com/agentrhq/webcmd/compare/webcmd-v0.7.6...webcmd-v0.7.7) (2026-08-26) ### Improvements From 3a0a5e5961aecf7db3fe2460af8babb26ed39a35 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 16:31:00 +0530 Subject: [PATCH 04/13] Revert "docs: changelog for hosted external CLI passthrough" This reverts commit b1b19b1d3d59d4c1e1aa554ae898072b014f2e2d. --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aca544d..f558430b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,5 @@ # Changelog -## Unreleased - -### Added - -- Hosted mode executes registered external CLIs (`webcmd gh pr list`) on the caller's machine. Nothing is sent to Cloud, and a hosted site always wins over an external of the same name. - ## [0.7.7](https://github.com/agentrhq/webcmd/compare/webcmd-v0.7.6...webcmd-v0.7.7) (2026-08-26) ### Improvements From 42c09f8248b8492a932ce8f82e2fe2aea41a8694 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 17:32:10 +0530 Subject: [PATCH 05/13] fix(hosted): secure external CLI passthrough --- src/hosted/external.test.ts | 18 ++++++++++++++++ src/hosted/main-lifecycle.test.ts | 36 +++++++++++++++++++++++++++++++ src/hosted/programmatic.test.ts | 29 +++++++++++++++++++++++++ src/hosted/runner.ts | 22 +++++++++++-------- src/main.ts | 8 +++++-- src/root-command-surface.ts | 5 +++-- 6 files changed, 105 insertions(+), 13 deletions(-) diff --git a/src/hosted/external.test.ts b/src/hosted/external.test.ts index c64964c8..a6182266 100644 --- a/src/hosted/external.test.ts +++ b/src/hosted/external.test.ts @@ -101,6 +101,24 @@ describe('hosted external CLI execution', () => { expect(String(h.fetchImpl.mock.calls[0]![0])).toBe('https://api.example.com/v1/manifest'); }); + it('keeps an external suffix workspace flag out of Cloud request metadata', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + await runHostedCli(['gh', 'issue', 'list', '--workspace', 'external-only-value'], h.opts); + + expect(run).toHaveBeenCalledWith( + 'gh', + ['issue', 'list', '--workspace', 'external-only-value'], + registry, + ); + expect(h.fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = h.fetchImpl.mock.calls[0]!; + expect(String(url)).toBe('https://api.example.com/v1/manifest'); + expect(new Headers(init?.headers).get('x-webcmd-workspace')).toBeNull(); + expect(String(init?.body ?? '')).not.toContain('external-only-value'); + }); + it('lets a hosted site win over an external of the same name', async () => { const run = vi.fn(() => 0); const h = harness(run); diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index dbe46f11..a24b03a6 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -152,6 +152,42 @@ describe('hosted CLI process lifecycle', () => { expect(fixture.requests).toEqual([]); }, 20_000); + it('runs external list locally without contacting Cloud when hosted mode is configured', async () => { + const fixture = await createHostedFixture('success'); + + const result = await runCli(['external', 'list', '-f', 'json'], fixture.env); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'gh', binary: 'gh' }), + ])); + expect(fixture.requests).toEqual([]); + await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }, 20_000); + + it('runs a registered external with its child arguments and exit code in hosted mode', async () => { + const fixture = await createHostedFixture('success'); + const registryPath = path.join(fixture.root, '.webcmd', 'external-clis.yaml'); + await writeFile(registryPath, JSON.stringify([{ + name: 'fixture-node', + binary: process.execPath, + }])); + + const result = await runCli([ + 'fixture-node', + '-e', + 'process.stdout.write(`external:${process.argv[1]}`); process.exit(3)', + 'child-value', + ], fixture.env); + + expect(result.status).toBe(3); + expect(result.stdout).toBe('external:child-value'); + expect(result.stderr).toBe(''); + expect(fixture.requests).toEqual(['GET /v1/manifest']); + await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }, 20_000); + it('keeps hosted auth on Cloud without local discovery', async () => { const fixture = await createHostedFixture('success'); diff --git a/src/hosted/programmatic.test.ts b/src/hosted/programmatic.test.ts index 1e49a587..6f8847f0 100644 --- a/src/hosted/programmatic.test.ts +++ b/src/hosted/programmatic.test.ts @@ -1,5 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { runHostedProgrammatic } from './programmatic.js'; +import { PKG_VERSION } from '../version.js'; + +const externalDefaults = vi.hoisted(() => ({ + list: vi.fn(() => [{ name: 'gh', binary: 'gh' }]), + run: vi.fn(() => 0), +})); + +vi.mock('../external.js', () => ({ + loadExternalClis: externalDefaults.list, + executeExternalCli: externalDefaults.run, +})); const manifest = { userId: 'u1', @@ -181,6 +192,24 @@ describe('runHostedProgrammatic', () => { expect(urls.some((url) => url.includes('/mcp'))).toBe(false); }); + it('does not load or execute local externals from the programmatic runner', async () => { + externalDefaults.list.mockClear(); + externalDefaults.run.mockClear(); + + const result = await runHostedProgrammatic({ + argv: ['gh', '--version'], + apiBaseUrl: 'http://127.0.0.1:8787', + accessToken: 't', + fetchImpl: fakeCloud(), + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(`${PKG_VERSION}\n`); + expect(result.stderr).toBe(''); + expect(externalDefaults.list).not.toHaveBeenCalled(); + expect(externalDefaults.run).not.toHaveBeenCalled(); + }); + it('truncates oversized stdout without failing the invocation', async () => { const big = 'x'.repeat(300 * 1024); const result = await runHostedProgrammatic({ diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 7a7bc88b..746073cd 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -35,7 +35,7 @@ import { BrowserRunError } from '../browser/run/types.js'; import { CLI_COMMAND } from '../brand.js'; import { formatPluginSearchEmptyCopy, presentPluginSearch } from '../plugin-search-presentation.js'; import { missingPluginGuidance } from '../discovery.js'; -import { executeExternalCli, loadExternalClis, type ExternalCliConfig } from '../external.js'; +import type { ExternalCliConfig } from '../external.js'; import { webFetchCommand } from '../fetch/command.js'; import { runHostedArtifactDownload } from './artifact-download.js'; import { HostedClient, HostedClientError, resolveWorkspace } from './client.js'; @@ -98,7 +98,7 @@ export interface HostedRunnerOptions { files?: VirtualFileMap; /** When set, every file write lands here instead of the filesystem. */ outputs?: VirtualOutputSink; - /** Injection seam for the local external-CLI registry. Defaults to the real registry. */ + /** Explicitly grants access to the installed client's external registry and executor. */ externals?: { list(): ExternalCliConfig[]; run(name: string, args: string[], configs: ExternalCliConfig[]): number; @@ -173,10 +173,12 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { if (opts.signal?.aborted) { throw opts.signal.reason ?? new Error('The operation was aborted.'); } + const rootSurface = parseHostedRootCommandSurface(argv); const client = new HostedClient({ apiBaseUrl: config.hosted.apiBaseUrl, apiKey: credential.apiKey, - workspace: resolveWorkspace(argv, opts.env ?? process.env), + workspace: (rootSurface.kind === 'dispatch' ? rootSurface.workspace : undefined) + ?? resolveWorkspace([], opts.env ?? process.env), fetchImpl: opts.fetchImpl, ...(opts.signal ? { signal: opts.signal } : {}), }); @@ -202,7 +204,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { opts.enableServerWebFetch === true, opts.signal, opts.onTrustedCommandResolution, - opts.externals ?? { list: loadExternalClis, run: executeExternalCli }, + opts.externals, ); return { handled: true, exitCode: EXIT_CODES.SUCCESS }; } catch (caught) { @@ -268,10 +270,10 @@ async function dispatchHosted( enableServerWebFetch = false, signal?: AbortSignal, onResolvedCommand?: (resolution: TrustedCommandResolution) => void, - externals: { + externals?: { list(): ExternalCliConfig[]; run(name: string, args: string[], configs: ExternalCliConfig[]): number; - } = { list: loadExternalClis, run: executeExternalCli }, + }, ): Promise { const normalized = parseHostedRootCommandSurface(argv); if (normalized.kind === 'help') { @@ -518,9 +520,11 @@ async function dispatchHosted( // spawn. Nothing reaches Cloud. This runs before parseUnknownSiteRootOptions // so `webcmd gh --version` forwards --version to gh, matching the local // passThroughOptions() behavior instead of printing the webcmd version. - const externalConfigs = externals.list(); - if (externalConfigs.some(config => config.name === site)) { - throw new ExternalExitSignal(externals.run(site, args.slice(1), externalConfigs)); + if (externals) { + const externalConfigs = externals.list(); + if (externalConfigs.some(config => config.name === site)) { + throw new ExternalExitSignal(externals.run(site, args.slice(1), externalConfigs)); + } } const unknownRoot = parseUnknownSiteRootOptions(args, normalized.literal); if (unknownRoot.version) { diff --git a/src/main.ts b/src/main.ts index 55789529..8c3395ac 100644 --- a/src/main.ts +++ b/src/main.ts @@ -75,7 +75,7 @@ if (!fastPathHandled) { if (argv[0] === 'setup') { const { runHostedSetup } = await import('./hosted/setup.js'); process.exitCode = await runHostedSetup({ argv: argv.slice(1) }); - } else if (argv[0] === 'skills' || argv[0] === 'update') { + } else if (argv[0] === 'skills' || argv[0] === 'update' || argv[0] === 'external') { const { createProgram } = await import('./cli.js'); await createProgram(BUILTIN_CLIS, USER_CLIS).parseAsync(argv, { from: 'user' }); } else if (isWebFetch(argv)) { @@ -85,9 +85,13 @@ if (!fastPathHandled) { const { shouldUseHostedMode } = await import('./hosted/config.js'); if (shouldUseHostedMode()) { const { runHostedCli } = await import('./hosted/runner.js'); + const { executeExternalCli, loadExternalClis } = await import('./external.js'); // The installed CLI already owns local web/fetch transport authority. // Programmatic embedders remain opt-in and default to no network access. - const result = await runHostedCli(argv, { enableServerWebFetch: true }); + const result = await runHostedCli(argv, { + enableServerWebFetch: true, + externals: { list: loadExternalClis, run: executeExternalCli }, + }); process.exitCode = result.exitCode; } else { const { installDaemonRunSignalCancellation } = await import('./signal-cancel.js'); diff --git a/src/root-command-surface.ts b/src/root-command-surface.ts index e68f7690..619c32e9 100644 --- a/src/root-command-surface.ts +++ b/src/root-command-surface.ts @@ -26,7 +26,7 @@ export type HostedRootCommandSurface = | { kind: 'help'; exitCode: number } | { kind: 'version'; output: string } | { kind: 'completion'; argv: string[] } - | { kind: 'dispatch'; argv: string[]; profile?: string; session?: string; literal: boolean }; + | { kind: 'dispatch'; argv: string[]; profile?: string; session?: string; workspace?: string; literal: boolean }; /** * Parse only the root command surface without registering or discovering local @@ -89,7 +89,7 @@ export function parseHostedRootCommandSurface(argv: readonly string[]): HostedRo throw structuralErrorFromCommander(error, root, stderr); } - const { profile, session } = root.opts<{ profile?: string; session?: string }>(); + const { profile, session, workspace } = root.opts<{ profile?: string; session?: string; workspace?: string }>(); if (boundary.commandIndex === undefined && boundary.separatorIndex === undefined) return { kind: 'help', exitCode: 1 }; const literal = boundary.separatorIndex !== undefined; const parsedArgv = boundary.commandIndex !== undefined @@ -101,6 +101,7 @@ export function parseHostedRootCommandSurface(argv: readonly string[]): HostedRo argv: parsedArgv, ...(profile !== undefined ? { profile } : {}), ...(session !== undefined ? { session } : {}), + ...(workspace !== undefined ? { workspace } : {}), literal, }; } From 5e3a2df1619f4ccd05a704256eebee73c788b2da Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 17:47:11 +0530 Subject: [PATCH 06/13] fix(hosted): preserve external session arguments --- src/hosted/external.test.ts | 28 ++++++++++++++++++++++++++++ src/hosted/runner.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/hosted/external.test.ts b/src/hosted/external.test.ts index a6182266..fdd3ffaf 100644 --- a/src/hosted/external.test.ts +++ b/src/hosted/external.test.ts @@ -119,6 +119,34 @@ describe('hosted external CLI execution', () => { expect(String(init?.body ?? '')).not.toContain('external-only-value'); }); + it.each([ + { name: 'split', tail: ['--session', 'child-session'] }, + { name: 'equals', tail: ['--session=child-session'] }, + ])('forwards a child-owned $name session flag to the external', async ({ tail }) => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['gh', 'issue', 'list', ...tail], h.opts); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(run).toHaveBeenCalledWith('gh', ['issue', 'list', ...tail], registry); + expect(h.fetchImpl).toHaveBeenCalledTimes(1); + expect(h.stderr.text()).toBe(''); + }); + + it('applies session validation when a hosted site shadows the registered external', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['github', 'whoami', '--session=child-session'], h.opts); + + expect(result).toEqual({ handled: true, exitCode: 2 }); + expect(run).not.toHaveBeenCalled(); + expect(h.fetchImpl).toHaveBeenCalledTimes(1); + expect(String(h.fetchImpl.mock.calls[0]![0])).toBe('https://api.example.com/v1/manifest'); + expect(h.stderr.text()).toContain('SESSION_SELECTOR_POSITION'); + }); + it('lets a hosted site win over an external of the same name', async () => { const run = vi.fn(() => 0); const h = harness(run); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 746073cd..e91068a8 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -161,7 +161,24 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { const stderr = opts.stderr ?? process.stderr; try { - argv = rejectMisplacedSessionSelectorArgv(rejectPositionalBrowserSessionArgv(argv)); + argv = rejectPositionalBrowserSessionArgv(argv); + let deferredExternalSession: { + error: BrowserSessionArgvError; + site: string; + args: string[]; + configs: ExternalCliConfig[]; + } | undefined; + try { + argv = rejectMisplacedSessionSelectorArgv(argv); + } catch (error) { + if (!(error instanceof BrowserSessionArgvError) || !opts.externals) throw error; + const root = parseHostedRootCommandSurface(argv); + if (root.kind !== 'dispatch') throw error; + const [site, ...args] = root.argv; + const configs = opts.externals.list(); + if (!site || !configs.some(config => config.name === site)) throw error; + deferredExternalSession = { error, site, args, configs }; + } const credential = await resolveHostedApiKey(config, { credentialStore: opts.credentialStore, env: opts.env, @@ -182,6 +199,17 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { fetchImpl: opts.fetchImpl, ...(opts.signal ? { signal: opts.signal } : {}), }); + if (deferredExternalSession) { + const manifest = await getPresentationManifest(client, opts.enableServerWebFetch === true); + if (manifest.commands.some(command => command.site === deferredExternalSession.site)) { + throw deferredExternalSession.error; + } + throw new ExternalExitSignal(opts.externals!.run( + deferredExternalSession.site, + deferredExternalSession.args, + deferredExternalSession.configs, + )); + } const usesVirtualFileIo = opts.files !== undefined || opts.outputs !== undefined; const virtualFiles = opts.files ?? createVirtualFileMap([]); const virtualOutputs = opts.outputs ?? createVirtualOutputSink(); From 6958b185edaf146cff914c4d30b68ee353c7284a Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 17:53:41 +0530 Subject: [PATCH 07/13] fix(hosted): preserve core command precedence --- src/hosted/external.test.ts | 13 +++++++++++++ src/hosted/runner.ts | 36 +++++++++++++++++------------------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/hosted/external.test.ts b/src/hosted/external.test.ts index fdd3ffaf..0a999206 100644 --- a/src/hosted/external.test.ts +++ b/src/hosted/external.test.ts @@ -46,6 +46,7 @@ function manifestResponse(): Response { const registry: ExternalCliConfig[] = [ { name: 'gh', binary: 'gh', description: 'GitHub CLI' }, { name: 'github', binary: 'gh-shadow', description: 'Shadows a hosted site name' }, + { name: 'profile', binary: 'profile-shadow', description: 'Shadows a Webcmd root command' }, ]; type RunFn = (name: string, args: string[], configs: ExternalCliConfig[]) => number; @@ -147,6 +148,18 @@ describe('hosted external CLI execution', () => { expect(h.stderr.text()).toContain('SESSION_SELECTOR_POSITION'); }); + it('keeps a Webcmd root command ahead of a same-name external candidate', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['profile', 'list', '--session=child-session'], h.opts); + + expect(result).toEqual({ handled: true, exitCode: 2 }); + expect(run).not.toHaveBeenCalled(); + expect(h.fetchImpl).not.toHaveBeenCalled(); + expect(h.stderr.text()).toContain("unknown option '--session=child-session'"); + }); + it('lets a hosted site win over an external of the same name', async () => { const run = vi.fn(() => 0); const h = harness(run); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index e91068a8..a2e22b44 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -124,6 +124,13 @@ interface TrustedCommandResolution { accessClass: 'read' | 'write'; } +interface DeferredExternalSession { + error: BrowserSessionArgvError; + site: string; + args: string[]; + configs: ExternalCliConfig[]; +} + /** Carries an external CLI's own exit code out of dispatch. Not an error. */ class ExternalExitSignal { constructor(readonly exitCode: number) {} @@ -162,12 +169,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { try { argv = rejectPositionalBrowserSessionArgv(argv); - let deferredExternalSession: { - error: BrowserSessionArgvError; - site: string; - args: string[]; - configs: ExternalCliConfig[]; - } | undefined; + let deferredExternalSession: DeferredExternalSession | undefined; try { argv = rejectMisplacedSessionSelectorArgv(argv); } catch (error) { @@ -199,17 +201,6 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { fetchImpl: opts.fetchImpl, ...(opts.signal ? { signal: opts.signal } : {}), }); - if (deferredExternalSession) { - const manifest = await getPresentationManifest(client, opts.enableServerWebFetch === true); - if (manifest.commands.some(command => command.site === deferredExternalSession.site)) { - throw deferredExternalSession.error; - } - throw new ExternalExitSignal(opts.externals!.run( - deferredExternalSession.site, - deferredExternalSession.args, - deferredExternalSession.configs, - )); - } const usesVirtualFileIo = opts.files !== undefined || opts.outputs !== undefined; const virtualFiles = opts.files ?? createVirtualFileMap([]); const virtualOutputs = opts.outputs ?? createVirtualOutputSink(); @@ -233,6 +224,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { opts.signal, opts.onTrustedCommandResolution, opts.externals, + deferredExternalSession, ); return { handled: true, exitCode: EXIT_CODES.SUCCESS }; } catch (caught) { @@ -302,6 +294,7 @@ async function dispatchHosted( list(): ExternalCliConfig[]; run(name: string, args: string[], configs: ExternalCliConfig[]): number; }, + deferredExternalSession?: DeferredExternalSession, ): Promise { const normalized = parseHostedRootCommandSurface(argv); if (normalized.kind === 'help') { @@ -543,15 +536,20 @@ async function dispatchHosted( const site = args[0]!; const commandName = args[1]; const siteExists = manifest.commands.some(command => command.site === site); + if (siteExists && deferredExternalSession) throw deferredExternalSession.error; if (!siteExists) { // Externals are local binaries, not adapters: registry lookup, PATH check, // spawn. Nothing reaches Cloud. This runs before parseUnknownSiteRootOptions // so `webcmd gh --version` forwards --version to gh, matching the local // passThroughOptions() behavior instead of printing the webcmd version. if (externals) { - const externalConfigs = externals.list(); + const externalConfigs = deferredExternalSession?.configs ?? externals.list(); if (externalConfigs.some(config => config.name === site)) { - throw new ExternalExitSignal(externals.run(site, args.slice(1), externalConfigs)); + throw new ExternalExitSignal(externals.run( + site, + deferredExternalSession?.args ?? args.slice(1), + externalConfigs, + )); } } const unknownRoot = parseUnknownSiteRootOptions(args, normalized.literal); From 9a614a790ab02d04f622fb60f49a042cd17d9a47 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 18:20:59 +0530 Subject: [PATCH 08/13] fix(hosted): harden external command routing --- src/cli.test.ts | 7 +++++ src/cli.ts | 1 + src/hooks.ts | 4 +-- src/hosted/external.test.ts | 26 +++++++++++++++++++ src/hosted/main-lifecycle.test.ts | 17 +++++++++++++ src/hosted/root-command-surface.test.ts | 15 ++++++----- src/hosted/runner.ts | 31 +++++++++++----------- src/main.ts | 34 +++++++++++++++++-------- src/root-command-surface.ts | 23 +++++++++++++---- 9 files changed, 119 insertions(+), 39 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 9bf6f584..f138106e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2862,6 +2862,13 @@ describe('builtin output formats', () => { expect(aliasResult.stdout).toBe(yamlResult.stdout); }); + it('sets a nonzero exit code when external install reports failure', async () => { + const result = await run('external', 'install', 'ntn', '-f', 'json'); + + expect(result.exitCode).toBe(EXIT_CODES.SERVICE_UNAVAIL); + expect(JSON.parse(result.stdout)).toMatchObject({ ok: false, action: 'install', cli: 'ntn' }); + }); + it('renders an empty plugin list as structured data', async () => { const list = vi.spyOn(pluginModule, 'listPlugins').mockReturnValue([] as never); try { diff --git a/src/cli.ts b/src/cli.ts index 09bc4852..6aa394e1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2232,6 +2232,7 @@ cli({ return; } const installed = installExternalCli(ext); + if (!installed) process.exitCode = EXIT_CODES.SERVICE_UNAVAIL; await emitActionResult(command, { ok: installed, action: 'install', diff --git a/src/hooks.ts b/src/hooks.ts index b3ea8600..929b1364 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -83,7 +83,7 @@ export async function emitHook(name: HookName, ctx: HookContext, result?: unknow } } -const BUILTIN_COMMANDS = new Set([ +export const WEBCMD_ROOT_COMMANDS: ReadonlySet = new Set([ 'agent-context', 'adapter', 'auth', @@ -106,7 +106,7 @@ const BUILTIN_COMMANDS = new Set([ export function shouldRunStartupSideEffects(argv: readonly string[]): boolean { if (isHelp(argv)) return false; - return !(hasExplicitOutputFormat(argv) && BUILTIN_COMMANDS.has(rootCommand(argv) ?? '')); + return !(hasExplicitOutputFormat(argv) && WEBCMD_ROOT_COMMANDS.has(rootCommand(argv) ?? '')); } export function shouldEmitStartupHook(argv: readonly string[]): boolean { diff --git a/src/hosted/external.test.ts b/src/hosted/external.test.ts index 0a999206..1499c325 100644 --- a/src/hosted/external.test.ts +++ b/src/hosted/external.test.ts @@ -47,6 +47,7 @@ const registry: ExternalCliConfig[] = [ { name: 'gh', binary: 'gh', description: 'GitHub CLI' }, { name: 'github', binary: 'gh-shadow', description: 'Shadows a hosted site name' }, { name: 'profile', binary: 'profile-shadow', description: 'Shadows a Webcmd root command' }, + { name: 'validate', binary: 'validate-shadow', description: 'Shadows a local-only Webcmd root command' }, ]; type RunFn = (name: string, args: string[], configs: ExternalCliConfig[]) => number; @@ -160,6 +161,31 @@ describe('hosted external CLI execution', () => { expect(h.stderr.text()).toContain("unknown option '--session=child-session'"); }); + it('keeps a local-only Webcmd root ahead of external fallback', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['validate'], h.opts); + + expect(result).toEqual({ handled: true, exitCode: 78 }); + expect(run).not.toHaveBeenCalled(); + expect(h.stderr.text()).toContain('webcmd validate is local-only'); + }); + + it.each([ + { name: 'direct', tail: ['--get-completions'] }, + { name: 'after separator', tail: ['--', '--get-completions'] }, + ])('forwards child-owned completion sentinel $name to the external', async ({ tail }) => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['gh', ...tail], h.opts); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(run).toHaveBeenCalledWith('gh', tail, registry); + expect(h.stderr.text()).toBe(''); + }); + it('lets a hosted site win over an external of the same name', async () => { const run = vi.fn(() => 0); const h = harness(run); diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index a24b03a6..78753432 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -166,6 +166,23 @@ describe('hosted CLI process lifecycle', () => { await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); }, 20_000); + it.each([ + { name: 'profile', selectors: ['--profile', 'work'] }, + { name: 'workspace', selectors: ['--workspace', 'ws'] }, + ])('runs external list locally with a leading $name selector', async ({ selectors }) => { + const fixture = await createHostedFixture('success'); + + const result = await runCli([...selectors, 'external', 'list', '-f', 'json'], fixture.env); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'gh', binary: 'gh' }), + ])); + expect(fixture.requests).toEqual([]); + await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }, 20_000); + it('runs a registered external with its child arguments and exit code in hosted mode', async () => { const fixture = await createHostedFixture('success'); const registryPath = path.join(fixture.root, '.webcmd', 'external-clis.yaml'); diff --git a/src/hosted/root-command-surface.test.ts b/src/hosted/root-command-surface.test.ts index 600a7922..24acc657 100644 --- a/src/hosted/root-command-surface.test.ts +++ b/src/hosted/root-command-surface.test.ts @@ -348,10 +348,15 @@ describe('hosted root command surface', () => { ); it.each(profileForms.flatMap(profile => dispatchForms.map(dispatch => ({ profile, dispatch }))))( - 'gives completion priority across $profile.name/$dispatch.name', + 'leaves a child-owned completion sentinel with $profile.name/$dispatch.name', ({ profile, dispatch }) => { const argv = [...profile.tokens, ...dispatch.tokens, '--get-completions']; - expect(parseHostedRootCommandSurface(argv)).toEqual({ kind: 'completion', argv }); + expect(parseHostedRootCommandSurface(argv)).toEqual({ + kind: 'dispatch', + argv: [...dispatch.tokens, '--get-completions'], + ...(profile.profile !== undefined ? { profile: profile.profile } : {}), + literal: false, + }); }, ); @@ -360,10 +365,8 @@ describe('hosted root command surface', () => { { name: 'beats help', argv: ['--help', '--get-completions'] }, { name: 'beats unknown', argv: ['--unknown', '--get-completions'] }, { name: 'beats missing profile', argv: ['--profile', '--get-completions'] }, - { name: 'after separator', argv: ['--', '--get-completions'] }, - { name: 'after site tokens', argv: ['github', 'whoami', '--get-completions'] }, { name: 'beats non-fast clustered version', argv: ['-Vx', '--get-completions'] }, - ])('matches the local main completion fast-path: $name', ({ argv }) => { + ])('recognizes completion on the Webcmd root surface: $name', ({ argv }) => { expect(parseHostedRootCommandSurface(argv)).toEqual({ kind: 'completion', argv }); }); @@ -416,7 +419,7 @@ describe('hosted root preflight call order', () => { ['--help', '--get-completions'], ['--unknown', '--get-completions'], ['--profile', '--get-completions'], - ['--', '--get-completions'], + ['--get-completions', '--'], ['-Vx', '--get-completions'], ])('completion preflight performs exactly one manifest request and no execute: %j', async (...argv) => { const stdout = sink(); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index a2e22b44..6e5745f2 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -23,6 +23,7 @@ import { import { splitAdapterCommandKey } from '../adapter-source.js'; import { ArgumentError, CliError, ConfigError, EXIT_CODES, InterruptedError, toEnvelope } from '../errors.js'; import { getRequestedHelpFormat, renderStructuredHelp } from '../help.js'; +import { WEBCMD_ROOT_COMMANDS } from '../hooks.js'; import { enableVerbose } from '../logger.js'; import { findPackageRoot } from '../package-paths.js'; import { errorEnvelopeFormat, formatErrorEnvelope, requestedFormatFromArgv, requestedMachineFormat, render as renderOutput } from '../output.js'; @@ -126,16 +127,10 @@ interface TrustedCommandResolution { interface DeferredExternalSession { error: BrowserSessionArgvError; - site: string; args: string[]; configs: ExternalCliConfig[]; } -/** Carries an external CLI's own exit code out of dispatch. Not an error. */ -class ExternalExitSignal { - constructor(readonly exitCode: number) {} -} - class CommanderCompatibleError extends Error { constructor( readonly output: string, @@ -179,7 +174,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { const [site, ...args] = root.argv; const configs = opts.externals.list(); if (!site || !configs.some(config => config.name === site)) throw error; - deferredExternalSession = { error, site, args, configs }; + deferredExternalSession = { error, args, configs }; } const credential = await resolveHostedApiKey(config, { credentialStore: opts.credentialStore, @@ -212,7 +207,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { fileIo, ...(usesVirtualFileIo ? { virtualScaffold: { files: virtualFiles, outputs: virtualOutputs } } : {}), }; - await dispatchHosted( + const exitCode = await dispatchHosted( argv, client, stdout, @@ -226,12 +221,9 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { opts.externals, deferredExternalSession, ); - return { handled: true, exitCode: EXIT_CODES.SUCCESS }; + return { handled: true, exitCode: exitCode ?? EXIT_CODES.SUCCESS }; } catch (caught) { if (caught instanceof StreamWriteError) throw caught; - if (caught instanceof ExternalExitSignal) { - return { handled: true, exitCode: caught.exitCode }; - } const err = opts.signal?.aborted ? new InterruptedError() : caught; if (err instanceof BrowserSessionArgvError) { await writeToStream(stderr, `error: ${err.message}\n`); @@ -295,7 +287,7 @@ async function dispatchHosted( run(name: string, args: string[], configs: ExternalCliConfig[]): number; }, deferredExternalSession?: DeferredExternalSession, -): Promise { +): Promise { const normalized = parseHostedRootCommandSurface(argv); if (normalized.kind === 'help') { const help = formatRootHelp(HOSTED_ROOT_HELP); @@ -545,11 +537,14 @@ async function dispatchHosted( if (externals) { const externalConfigs = deferredExternalSession?.configs ?? externals.list(); if (externalConfigs.some(config => config.name === site)) { - throw new ExternalExitSignal(externals.run( + if (isWebcmdOwnedRoot(site)) { + throw new ConfigError(`${CLI_COMMAND} ${site} is local-only and is not available in hosted mode.`, LOCAL_ONLY_COMMAND_HELP); + } + return externals.run( site, deferredExternalSession?.args ?? args.slice(1), externalConfigs, - )); + ); } } const unknownRoot = parseUnknownSiteRootOptions(args, normalized.literal); @@ -1668,6 +1663,12 @@ function parseUnknownSiteRootOptions( return { help, version: false, ...(profile !== undefined ? { profile } : {}) }; } +function isWebcmdOwnedRoot(name: string): boolean { + return WEBCMD_ROOT_COMMANDS.has(name) + || HOSTED_ROOT_HELP.commands.some(command => command.name.split(/\s/, 1)[0] === name) + || HOSTED_ROOT_HELP.localOnlyCommands?.some(command => command.name.split(/\s/, 1)[0] === name) === true; +} + function hasTerminalBeforeSeparator( argv: readonly string[], predicate: (token: string) => boolean, diff --git a/src/main.ts b/src/main.ts index 8c3395ac..118b8294 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,7 +23,7 @@ import { PKG_VERSION } from './version.js'; import { EXIT_CODES } from './errors.js'; import { isSupportedNodeVersion, MIN_SUPPORTED_NODE_MAJOR } from './runtime-detect.js'; import { CONFIG_DIR_NAME } from './brand.js'; -import { parseHostedRootCommandSurface } from './root-command-surface.js'; +import { configureHostedWorkspaceOption, parseHostedRootCommandSurface, rootCompletionSentinelIndex } from './root-command-surface.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -72,13 +72,26 @@ if (!fastPathHandled && argv[0] === 'completion' && argv.length >= 2) { // memory just to decide what commands exist. Awaiting the selected branch and // assigning exitCode lets Node flush pending stdout/stderr before shutdown. if (!fastPathHandled) { + const rootSurface = parseRootSurface(argv); if (argv[0] === 'setup') { const { runHostedSetup } = await import('./hosted/setup.js'); process.exitCode = await runHostedSetup({ argv: argv.slice(1) }); - } else if (argv[0] === 'skills' || argv[0] === 'update' || argv[0] === 'external') { + } else if ( + argv[0] === 'skills' + || argv[0] === 'update' + || (rootSurface?.kind === 'dispatch' && rootSurface.argv[0] === 'external') + ) { const { createProgram } = await import('./cli.js'); - await createProgram(BUILTIN_CLIS, USER_CLIS).parseAsync(argv, { from: 'user' }); - } else if (isWebFetch(argv)) { + const program = createProgram(BUILTIN_CLIS, USER_CLIS); + if (rootSurface?.kind === 'dispatch' && rootSurface.workspace !== undefined) { + configureHostedWorkspaceOption(program); + } + await program.parseAsync(argv, { from: 'user' }); + } else if ( + rootSurface?.kind === 'dispatch' + && rootSurface.argv[0] === 'web' + && rootSurface.argv[1] === 'fetch' + ) { const { runWebFetchCommand } = await import('./fetch/command.js'); await runWebFetchCommand(argv); } else { @@ -105,18 +118,17 @@ if (!fastPathHandled) { } } -function isWebFetch(args: readonly string[]): boolean { +function parseRootSurface(args: readonly string[]) { try { - const parsed = parseHostedRootCommandSurface(args); - return parsed.kind === 'dispatch' && parsed.argv[0] === 'web' && parsed.argv[1] === 'fetch'; + return parseHostedRootCommandSurface(args); } catch { - return false; + return undefined; } } async function runLocalMain(): Promise { // Fast path: --get-completions — read from manifest, skip discovery -const getCompIdx = process.argv.indexOf('--get-completions'); +const getCompIdx = rootCompletionSentinelIndex(argv); if (getCompIdx !== -1) { // Only include manifests that actually exist on disk. // With sparse override, the user clis dir may exist but have no manifest. @@ -127,7 +139,7 @@ if (getCompIdx !== -1) { const userManifest = getCliManifestPath(USER_CLIS); try { fs.accessSync(userManifest); manifestPaths.push(userManifest); } catch { uncoveredCommandRoots.push(USER_CLIS); } if (hasAllManifests(manifestPaths, uncoveredCommandRoots)) { - const rest = process.argv.slice(getCompIdx + 1); + const rest = argv.slice(getCompIdx + 1); let cursor: number | undefined; const words: string[] = []; for (let i = 0; i < rest.length; i++) { @@ -186,7 +198,7 @@ if (runStartupSideEffects) { // ── Fallback completion: manifest unavailable, use full registry ───────── if (getCompIdx !== -1) { - const rest = process.argv.slice(getCompIdx + 1); + const rest = argv.slice(getCompIdx + 1); let cursor: number | undefined; const words: string[] = []; for (let i = 0; i < rest.length; i++) { diff --git a/src/root-command-surface.ts b/src/root-command-surface.ts index 619c32e9..27fdf8fc 100644 --- a/src/root-command-surface.ts +++ b/src/root-command-surface.ts @@ -7,6 +7,8 @@ export const ROOT_PROFILE_DESCRIPTION = 'Chrome profile/context alias for browse export const ROOT_SESSION_FLAGS = '--session '; export const ROOT_SESSION_DESCRIPTION = 'Existing readable Session ID from `webcmd session create `'; export const ROOT_SESSION_SELECTOR_POSITION = 'root'; +const ROOT_WORKSPACE_FLAGS = '--workspace '; +const ROOT_WORKSPACE_DESCRIPTION = 'Hosted workspace id/slug for the request'; export const COMPLETION_SENTINEL = '--get-completions'; /** @@ -22,6 +24,10 @@ export function configureRootCommandSurface(program: Command): Command { .enablePositionalOptions(); } +export function configureHostedWorkspaceOption(program: Command): Command { + return program.option(ROOT_WORKSPACE_FLAGS, ROOT_WORKSPACE_DESCRIPTION); +} + export type HostedRootCommandSurface = | { kind: 'help'; exitCode: number } | { kind: 'version'; output: string } @@ -40,21 +46,20 @@ export function parseHostedRootCommandSurface(argv: readonly string[]): HostedRo if (input[0] === '--version' || input[0] === '-V') { return { kind: 'version', output: `${PKG_VERSION}\n` }; } - // The local completion path scans the complete raw argv before discovery or - // Commander parsing, including after `--` and malformed root options. - if (input.includes(COMPLETION_SENTINEL)) { + // Completion is a Webcmd root sentinel. Once a command or `--` begins the + // child surface, the same token belongs to that command and must be kept. + if (rootCompletionSentinelIndex(input) !== -1) { return { kind: 'completion', argv: input }; } let stdout = ''; let stderr = ''; const boundary = findRootCommandBoundary(input); - const root = configureRootCommandSurface(new Command('webcmd')) + const root = configureHostedWorkspaceOption(configureRootCommandSurface(new Command('webcmd'))) // Hosted-only: registered here (not in the shared configureRootCommandSurface) // so the local CLI surface is unaffected. Lets Commander's structural parse // consume `--workspace ` before the site/command token instead of // throwing an unknown-option error. - .option('--workspace ', 'Hosted workspace id/slug for the request') .exitOverride() .configureOutput({ writeOut: value => { stdout += value; }, @@ -111,6 +116,14 @@ interface RootCommandBoundary { separatorIndex?: number; } +export function rootCompletionSentinelIndex(argv: readonly string[]): number { + const index = argv.indexOf(COMPLETION_SENTINEL); + if (index === -1) return -1; + const boundary = findRootCommandBoundary(argv); + const childIndex = boundary.commandIndex ?? boundary.separatorIndex; + return childIndex === undefined || index < childIndex ? index : -1; +} + /** Locates the undiscovered command token while respecting root value options. */ function findRootCommandBoundary(argv: readonly string[]): RootCommandBoundary { for (let index = 0; index < argv.length; index += 1) { From a1396f099536e1192f65e47b2efe5a9267185aba Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 20:24:26 +0530 Subject: [PATCH 09/13] fix(cli): preserve runtime command ownership --- src/cli.ts | 12 +++-- src/hosted/external.test.ts | 36 ++++++++++++++- src/hosted/main-lifecycle.test.ts | 74 +++++++++++++++++++++++++++++++ src/hosted/runner.ts | 13 ++++-- src/main.ts | 14 ++++-- 5 files changed, 137 insertions(+), 12 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 6aa394e1..352f6177 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -68,6 +68,7 @@ import { resolveAdapterSourcePath, splitAdapterCommandKey } from './adapter-sour const CLI_FILE = fileURLToPath(import.meta.url); const FOLLOW_POLL_MS = 1_000; +const externalRootCommands = new WeakSet(); function getBrowserCacheDir(): string { return process.env.WEBCMD_CACHE_DIR || path.join(os.homedir(), '.webcmd', 'cache'); @@ -2297,7 +2298,7 @@ cli({ for (const ext of externalClis) { if (program.commands.some(c => c.name() === ext.name)) continue; - program + const command = program .command(ext.name) .description(`(External) ${ext.description || ext.name}`) .argument('[args...]') @@ -2305,6 +2306,7 @@ cli({ .passThroughOptions() .helpOption(false) .action((args: string[]) => passthroughExternal(ext.name, args)); + externalRootCommands.add(command); } // ── Antigravity serve (long-running, special case) ──────────────────────── @@ -2460,8 +2462,12 @@ export async function loadAntigravityServe(pluginsDir: string = PLUGINS_DIR): Pr * surfaced as an unhandled rejection, and the `exitCode` the error carried was * lost. `parseAsync` lets the rejection reach this catch. */ -export async function runCli(BUILTIN_CLIS: string, USER_CLIS: string): Promise { - const program = createProgram(BUILTIN_CLIS, USER_CLIS); +export function isExternalRootCommand(program: Command, name: string | undefined): boolean { + const command = program.commands.find(candidate => candidate.name() === name); + return command !== undefined && externalRootCommands.has(command); +} + +export async function runCli(BUILTIN_CLIS: string, USER_CLIS: string, program = createProgram(BUILTIN_CLIS, USER_CLIS)): Promise { applyUnknownOptionContract(program); try { await program.parseAsync(); diff --git a/src/hosted/external.test.ts b/src/hosted/external.test.ts index 2a9a39b0..8083b623 100644 --- a/src/hosted/external.test.ts +++ b/src/hosted/external.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { makeHostedConfig } from './config.js'; import { runHostedCli } from './runner.js'; import type { ExternalCliConfig } from '../external.js'; +import type { HostedCredentialStore } from './credentials.js'; import { PKG_VERSION } from '../version.js'; function sink(): { stream: Writable; text: () => string } { @@ -150,7 +151,7 @@ describe('hosted external CLI execution', () => { expect(h.stderr.text()).toContain('SESSION_SELECTOR_POSITION'); }); - it('keeps a Webcmd root command ahead of a same-name external candidate', async () => { + it('validates a Webcmd root before a same-name external candidate', async () => { const run = vi.fn(() => 0); const h = harness(run); @@ -159,7 +160,7 @@ describe('hosted external CLI execution', () => { expect(result).toEqual({ handled: true, exitCode: 2 }); expect(run).not.toHaveBeenCalled(); expect(h.fetchImpl).not.toHaveBeenCalled(); - expect(h.stderr.text()).toContain("unknown option '--session=child-session'"); + expect(h.stderr.text()).toContain('SESSION_SELECTOR_POSITION'); }); it('keeps a local-only Webcmd root ahead of external fallback', async () => { @@ -173,6 +174,37 @@ describe('hosted external CLI execution', () => { expect(h.stderr.text()).toContain('webcmd validate is local-only'); }); + it('validates a Webcmd root before consulting a same-name external or hosted preflight', async () => { + const run = vi.fn(() => 0); + const list = vi.fn(() => registry); + const getCredential = vi.fn(async () => 'key'); + const credentialStore: HostedCredentialStore = { + get: getCredential, + put: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + backend: () => 'os', + }; + const h = harness(run); + + const result = await runHostedCli(['validate', '--session=child-session'], { + ...h.opts, + config: makeHostedConfig({ + apiBaseUrl: 'https://api.example.com', + apiKeyRef: 'wcmd_validate_collision', + credentialBackend: 'os', + }), + credentialStore, + externals: { list, run }, + }); + + expect(result).toEqual({ handled: true, exitCode: 2 }); + expect(h.stderr.text()).toContain('SESSION_SELECTOR_POSITION'); + expect(list).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(getCredential).not.toHaveBeenCalled(); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + it('runs an external whose name is not a registered Webcmd root', async () => { const run = vi.fn(() => 0); const h = harness(run); diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index 1c8f0512..50484074 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -205,6 +205,44 @@ describe('hosted CLI process lifecycle', () => { await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); }, 20_000); + it.each([ + { name: 'split', tail: ['--session', 'child-session'] }, + { name: 'equals', tail: ['--session=child-session'] }, + ])('forwards a child-owned $name session flag to a registered external in local mode', async ({ tail }) => { + const fixture = await createLocalExternalFixture('fixture-node'); + + const result = await runCli(['fixture-node', fixture.script, ...tail], fixture.env); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(JSON.stringify(tail)); + expect(result.stderr).toBe(''); + }, 20_000); + + it.each([ + { installed: false, status: 0 }, + { installed: true, status: 78 }, + ])('treats optional antigravity as Webcmd-owned only when installed: $installed', async ({ installed, status }) => { + const fixture = await createHostedFixture('success'); + const script = await registerArgvExternal(fixture.root, 'antigravity'); + if (installed) { + const pluginDir = path.join(fixture.root, '.webcmd', 'plugins', 'antigravity'); + await mkdir(pluginDir, { recursive: true }); + await writeFile(path.join(pluginDir, 'serve.js'), 'export async function startServe() {}\n'); + } + + const result = await runCli(['antigravity', script, 'child-value'], fixture.env); + + expect(result.status).toBe(status); + expect(fixture.requests).toEqual(['GET /v1/manifest']); + if (installed) { + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('webcmd antigravity is local-only'); + } else { + expect(result.stdout).toBe(JSON.stringify(['child-value'])); + expect(result.stderr).toBe(''); + } + }, 20_000); + it.each([ { name: 'profile before skills', @@ -483,6 +521,42 @@ async function createLocalStartupPluginFixture(): Promise<{ root: string; env: N }; } +async function createLocalExternalFixture(name: string): Promise<{ + root: string; + env: NodeJS.ProcessEnv; + script: string; +}> { + const root = await mkdtemp(path.join(tmpdir(), 'webcmd-local-external-')); + tempRoots.push(root); + const configDir = path.join(root, 'config'); + await mkdir(configDir, { recursive: true }); + await writeFile(path.join(configDir, 'config.json'), '{"mode":"local"}\n'); + const script = await registerArgvExternal(root, name); + return { + root, + script, + env: { + ...process.env, + HOME: root, + USERPROFILE: root, + WEBCMD_CONFIG_DIR: configDir, + WEBCMD_NO_UPDATE_CHECK: '1', + }, + }; +} + +async function registerArgvExternal(root: string, name: string): Promise { + const registryDir = path.join(root, '.webcmd'); + const script = path.join(root, `${name}-argv.mjs`); + await mkdir(registryDir, { recursive: true }); + await writeFile(path.join(registryDir, 'external-clis.yaml'), JSON.stringify([{ + name, + binary: process.execPath, + }])); + await writeFile(script, "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n"); + return script; +} + function sendChunkedJson(response: import('node:http').ServerResponse, value: unknown, status = 200): void { const body = JSON.stringify(value); const split = Math.floor(body.length / 2); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 182a996b..1e7777a7 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -108,6 +108,8 @@ export interface HostedRunnerOptions { list(): ExternalCliConfig[]; run(name: string, args: string[], configs: ExternalCliConfig[]): number; }; + /** Optional local roots that are owned only when installed on this client. */ + installedLocalCommandRoots?: ReadonlySet; } interface HostedDispatchIo { @@ -176,8 +178,9 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { const root = parseHostedRootCommandSurface(argv); if (root.kind !== 'dispatch') throw error; const [site, ...args] = root.argv; + if (!site || isWebcmdOwnedRoot(site, opts.installedLocalCommandRoots)) throw error; const configs = opts.externals.list(); - if (!site || !configs.some(config => config.name === site)) throw error; + if (!configs.some(config => config.name === site)) throw error; deferredExternalSession = { error, args, configs }; } const credential = await resolveHostedApiKey(config, { @@ -224,6 +227,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { opts.signal, opts.onTrustedCommandResolution, opts.externals, + opts.installedLocalCommandRoots, deferredExternalSession, ); return { handled: true, exitCode: exitCode ?? EXIT_CODES.SUCCESS }; @@ -292,6 +296,7 @@ async function dispatchHosted( list(): ExternalCliConfig[]; run(name: string, args: string[], configs: ExternalCliConfig[]): number; }, + installedLocalCommandRoots?: ReadonlySet, deferredExternalSession?: DeferredExternalSession, ): Promise { const rootHelp = getHostedRootHelp(hasLocalClientCommandHandlers); @@ -547,7 +552,7 @@ async function dispatchHosted( if (externals) { const externalConfigs = deferredExternalSession?.configs ?? externals.list(); if (externalConfigs.some(config => config.name === site)) { - if (isWebcmdOwnedRoot(site)) { + if (isWebcmdOwnedRoot(site, installedLocalCommandRoots)) { throw new ConfigError(`${CLI_COMMAND} ${site} is local-only and is not available in hosted mode.`, LOCAL_ONLY_COMMAND_HELP); } return externals.run( @@ -1673,10 +1678,10 @@ function parseUnknownSiteRootOptions( return { help, version: false, ...(profile !== undefined ? { profile } : {}) }; } -function isWebcmdOwnedRoot(name: string): boolean { +function isWebcmdOwnedRoot(name: string, installedLocalCommandRoots?: ReadonlySet): boolean { return WEBCMD_ROOT_COMMANDS.has(name) || HOSTED_ROOT_HELP.commands.some(command => command.name.split(/\s/, 1)[0] === name) - || HOSTED_ROOT_HELP.localOnlyCommands?.some(command => command.name.split(/\s/, 1)[0] === name) === true; + || installedLocalCommandRoots?.has(name) === true; } function hasTerminalBeforeSeparator( diff --git a/src/main.ts b/src/main.ts index ed162f34..4c782007 100644 --- a/src/main.ts +++ b/src/main.ts @@ -106,6 +106,9 @@ if (!fastPathHandled) { enableServerWebFetch: true, hasLocalClientCommandHandlers: true, externals: { list: loadExternalClis, run: executeExternalCli }, + installedLocalCommandRoots: fs.existsSync(path.join(USER_PLUGINS, 'antigravity', 'serve.js')) + ? new Set(['antigravity']) + : undefined, }); process.exitCode = result.exitCode; } else { @@ -164,7 +167,7 @@ if (getCompIdx !== -1) { // Dynamic imports: these are deferred so the fast path above never pays the cost. const { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, PLUGINS_DIR } = await import('./discovery.js'); const { getCompletions } = await import('./completion.js'); -const { runCli } = await import('./cli.js'); +const { createProgram, isExternalRootCommand, runCli } = await import('./cli.js'); const { emitHook, shouldEmitStartupHook, shouldRunStartupSideEffects } = await import('./hooks.js'); const { installNodeNetwork } = await import('./node-network.js'); const { registerUpdateNoticeOnExit, checkForUpdateBackground } = await import('./update-check.js'); @@ -218,8 +221,13 @@ if (getCompIdx !== -1) { } const { rejectMisplacedSessionSelectorArgv, rejectPositionalBrowserSessionArgv, BrowserSessionArgvError, escapeLeadingDashPositional } = await import('./cli-argv-preprocess.js'); +const program = createProgram(BUILTIN_CLIS, USER_CLIS); try { - let rewritten = rejectMisplacedSessionSelectorArgv(rejectPositionalBrowserSessionArgv(process.argv.slice(2))); + let rewritten = rejectPositionalBrowserSessionArgv(process.argv.slice(2)); + const rootSurface = parseRootSurface(rewritten); + if (!(rootSurface?.kind === 'dispatch' && isExternalRootCommand(program, rootSurface.argv[0]))) { + rewritten = rejectMisplacedSessionSelectorArgv(rewritten); + } // Use the metadata that discovery actually registered. The core manifest is // intentionally empty, while installed plugins and legacy user CLIs are not. const { getRegistry } = await import('./registry.js'); @@ -236,5 +244,5 @@ try { if (shouldEmitStartupHook(argv)) { await emitHook('onStartup', { command: '__startup__', args: {} }); } -await runCli(BUILTIN_CLIS, USER_CLIS); +await runCli(BUILTIN_CLIS, USER_CLIS, program); } From a044c934e67e9ff94541055b7723d79e4a2f3823 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 20:36:14 +0530 Subject: [PATCH 10/13] fix(hosted): make core ownership registry-independent --- src/hooks.test.ts | 20 +++++++++++ src/hooks.ts | 1 + src/hosted/external.test.ts | 69 +++++++++++++++++++++++++++++++++++++ src/hosted/runner.ts | 16 ++++++--- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/hooks.test.ts b/src/hooks.test.ts index 22b21b52..d4ce6be7 100644 --- a/src/hooks.test.ts +++ b/src/hooks.test.ts @@ -3,6 +3,10 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { createProgram, isExternalRootCommand } from './cli.js'; import { onStartup, onBeforeExecute, @@ -11,6 +15,7 @@ import { clearAllHooks, shouldEmitStartupHook, shouldRunStartupSideEffects, + WEBCMD_ROOT_COMMANDS, type HookContext, } from './hooks.js'; @@ -110,6 +115,21 @@ describe('no-op when no hooks registered', () => { }); describe('startup hook gating', () => { + it('covers every unconditional createProgram root in the authoritative inventory', () => { + const pluginsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-root-inventory-')); + try { + const program = createProgram('', '', pluginsDir); + const missing = program.commands + .filter(command => !isExternalRootCommand(program, command.name())) + .map(command => command.name()) + .filter(name => !WEBCMD_ROOT_COMMANDS.has(name)); + + expect(missing).toEqual([]); + } finally { + fs.rmSync(pluginsDir, { recursive: true, force: true }); + } + }); + it.each([ ['--help'], ['list', '--format', 'json'], diff --git a/src/hooks.ts b/src/hooks.ts index 6ecdf51b..8da048bb 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -96,6 +96,7 @@ export const WEBCMD_ROOT_COMMANDS: ReadonlySet = new Set([ 'plugin', 'profile', 'session', + 'site', 'skills', 'update', 'validate', diff --git a/src/hosted/external.test.ts b/src/hosted/external.test.ts index 8083b623..0eb2e171 100644 --- a/src/hosted/external.test.ts +++ b/src/hosted/external.test.ts @@ -49,6 +49,7 @@ const registry: ExternalCliConfig[] = [ { name: 'github', binary: 'gh-shadow', description: 'Shadows a hosted site name' }, { name: 'agent-context', binary: 'agent-context', description: 'External with a retired Webcmd command name' }, { name: 'profile', binary: 'profile-shadow', description: 'Shadows a Webcmd root command' }, + { name: 'site', binary: 'site-shadow', description: 'Shadows the Webcmd site root command' }, { name: 'validate', binary: 'validate-shadow', description: 'Shadows a local-only Webcmd root command' }, ]; @@ -72,6 +73,34 @@ function harness(run: ReturnType>) { }; } +function ownershipHarness(withExternal: boolean) { + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(async () => manifestResponse()); + const list = vi.fn(() => registry); + const run = vi.fn(() => 0); + const getCredential = vi.fn(async () => 'key'); + const credentialStore: HostedCredentialStore = { + get: getCredential, + put: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + backend: () => 'os', + }; + const opts = { + config: makeHostedConfig({ + apiBaseUrl: 'https://api.example.com', + apiKeyRef: 'wcmd_ownership_collision', + credentialBackend: 'os', + }), + credentialStore, + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + ...(withExternal ? { externals: { list, run } } : {}), + }; + return { stdout, stderr, fetchImpl, list, run, getCredential, opts }; +} + describe('hosted external CLI execution', () => { it('spawns a registered external and returns its exit code', async () => { const run = vi.fn(() => 3); @@ -174,6 +203,46 @@ describe('hosted external CLI execution', () => { expect(h.stderr.text()).toContain('webcmd validate is local-only'); }); + it('keeps bare validate identical and side-effect free with or without a same-name external', async () => { + const variants = [ownershipHarness(false), ownershipHarness(true)]; + const results = []; + for (const variant of variants) { + results.push(await runHostedCli(['validate'], variant.opts)); + expect(variant.stdout.text()).toBe(''); + expect(variant.stderr.text()).toContain('webcmd validate is local-only'); + expect(variant.list).not.toHaveBeenCalled(); + expect(variant.run).not.toHaveBeenCalled(); + expect(variant.getCredential).not.toHaveBeenCalled(); + expect(variant.fetchImpl).not.toHaveBeenCalled(); + } + + expect(results).toEqual([ + { handled: true, exitCode: 78 }, + { handled: true, exitCode: 78 }, + ]); + expect(variants[1]!.stderr.text()).toBe(variants[0]!.stderr.text()); + }); + + it('keeps site session validation identical and side-effect free with or without a same-name external', async () => { + const variants = [ownershipHarness(false), ownershipHarness(true)]; + const results = []; + for (const variant of variants) { + results.push(await runHostedCli(['site', 'list', '--session=child-session'], variant.opts)); + expect(variant.stdout.text()).toBe(''); + expect(variant.stderr.text()).toContain('SESSION_SELECTOR_POSITION'); + expect(variant.list).not.toHaveBeenCalled(); + expect(variant.run).not.toHaveBeenCalled(); + expect(variant.getCredential).not.toHaveBeenCalled(); + expect(variant.fetchImpl).not.toHaveBeenCalled(); + } + + expect(results).toEqual([ + { handled: true, exitCode: 2 }, + { handled: true, exitCode: 2 }, + ]); + expect(variants[1]!.stderr.text()).toBe(variants[0]!.stderr.text()); + }); + it('validates a Webcmd root before consulting a same-name external or hosted preflight', async () => { const run = vi.fn(() => 0); const list = vi.fn(() => registry); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 1e7777a7..ecbe0d2b 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -183,6 +183,12 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { if (!configs.some(config => config.name === site)) throw error; deferredExternalSession = { error, args, configs }; } + const rootSurface = parseHostedRootCommandSurface(argv); + const rootName = rootSurface.kind === 'dispatch' ? rootSurface.argv[0] : undefined; + if (rootName === 'validate') { + throw new ConfigError(`${CLI_COMMAND} validate is local-only and is not available in hosted mode.`, LOCAL_ONLY_COMMAND_HELP); + } + const externals = rootName && isUnconditionalWebcmdRoot(rootName) ? undefined : opts.externals; const credential = await resolveHostedApiKey(config, { credentialStore: opts.credentialStore, env: opts.env, @@ -194,7 +200,6 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { if (opts.signal?.aborted) { throw opts.signal.reason ?? new Error('The operation was aborted.'); } - const rootSurface = parseHostedRootCommandSurface(argv); const client = new HostedClient({ apiBaseUrl: config.hosted.apiBaseUrl, apiKey: credential.apiKey, @@ -226,7 +231,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { opts.hasLocalClientCommandHandlers !== false, opts.signal, opts.onTrustedCommandResolution, - opts.externals, + externals, opts.installedLocalCommandRoots, deferredExternalSession, ); @@ -1679,9 +1684,12 @@ function parseUnknownSiteRootOptions( } function isWebcmdOwnedRoot(name: string, installedLocalCommandRoots?: ReadonlySet): boolean { + return isUnconditionalWebcmdRoot(name) || installedLocalCommandRoots?.has(name) === true; +} + +function isUnconditionalWebcmdRoot(name: string): boolean { return WEBCMD_ROOT_COMMANDS.has(name) - || HOSTED_ROOT_HELP.commands.some(command => command.name.split(/\s/, 1)[0] === name) - || installedLocalCommandRoots?.has(name) === true; + || HOSTED_ROOT_HELP.commands.some(command => command.name.split(/\s/, 1)[0] === name); } function hasTerminalBeforeSeparator( From eee6e3731fd59fe05640d535c9e2ebe30df24ab2 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 20:46:25 +0530 Subject: [PATCH 11/13] fix(hosted): make optional ownership registry-independent --- src/hooks.test.ts | 9 +++++---- src/hosted/external.test.ts | 24 ++++++++++++++++++++++++ src/hosted/main-lifecycle.test.ts | 2 +- src/hosted/runner.ts | 8 +++++--- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/hooks.test.ts b/src/hooks.test.ts index d4ce6be7..4d805f15 100644 --- a/src/hooks.test.ts +++ b/src/hooks.test.ts @@ -119,12 +119,13 @@ describe('startup hook gating', () => { const pluginsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-root-inventory-')); try { const program = createProgram('', '', pluginsDir); - const missing = program.commands + const actual = new Set(program.commands .filter(command => !isExternalRootCommand(program, command.name())) - .map(command => command.name()) - .filter(name => !WEBCMD_ROOT_COMMANDS.has(name)); + .map(command => command.name())); + const missing = [...actual].filter(name => !WEBCMD_ROOT_COMMANDS.has(name)).sort(); + const stale = [...WEBCMD_ROOT_COMMANDS].filter(name => !actual.has(name)).sort(); - expect(missing).toEqual([]); + expect({ missing, stale }).toEqual({ missing: [], stale: [] }); } finally { fs.rmSync(pluginsDir, { recursive: true, force: true }); } diff --git a/src/hosted/external.test.ts b/src/hosted/external.test.ts index 0eb2e171..2434219a 100644 --- a/src/hosted/external.test.ts +++ b/src/hosted/external.test.ts @@ -51,6 +51,7 @@ const registry: ExternalCliConfig[] = [ { name: 'profile', binary: 'profile-shadow', description: 'Shadows a Webcmd root command' }, { name: 'site', binary: 'site-shadow', description: 'Shadows the Webcmd site root command' }, { name: 'validate', binary: 'validate-shadow', description: 'Shadows a local-only Webcmd root command' }, + { name: 'antigravity', binary: 'antigravity-shadow', description: 'Shadows an optional Webcmd root command' }, ]; type RunFn = (name: string, args: string[], configs: ExternalCliConfig[]) => number; @@ -223,6 +224,29 @@ describe('hosted external CLI execution', () => { expect(variants[1]!.stderr.text()).toBe(variants[0]!.stderr.text()); }); + it('keeps installed antigravity identical and side-effect free with or without a same-name external', async () => { + const variants = [ownershipHarness(false), ownershipHarness(true)]; + const results = []; + for (const variant of variants) { + results.push(await runHostedCli(['antigravity', 'serve'], { + ...variant.opts, + installedLocalCommandRoots: new Set(['antigravity']), + })); + expect(variant.stdout.text()).toBe(''); + expect(variant.stderr.text()).toContain('webcmd antigravity is local-only'); + expect(variant.list).not.toHaveBeenCalled(); + expect(variant.run).not.toHaveBeenCalled(); + expect(variant.getCredential).not.toHaveBeenCalled(); + expect(variant.fetchImpl).not.toHaveBeenCalled(); + } + + expect(results).toEqual([ + { handled: true, exitCode: 78 }, + { handled: true, exitCode: 78 }, + ]); + expect(variants[1]!.stderr.text()).toBe(variants[0]!.stderr.text()); + }); + it('keeps site session validation identical and side-effect free with or without a same-name external', async () => { const variants = [ownershipHarness(false), ownershipHarness(true)]; const results = []; diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index 50484074..c30fe290 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -233,7 +233,7 @@ describe('hosted CLI process lifecycle', () => { const result = await runCli(['antigravity', script, 'child-value'], fixture.env); expect(result.status).toBe(status); - expect(fixture.requests).toEqual(['GET /v1/manifest']); + expect(fixture.requests).toEqual(installed ? [] : ['GET /v1/manifest']); if (installed) { expect(result.stdout).toBe(''); expect(result.stderr).toContain('webcmd antigravity is local-only'); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index ecbe0d2b..bf47d057 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -185,10 +185,12 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { } const rootSurface = parseHostedRootCommandSurface(argv); const rootName = rootSurface.kind === 'dispatch' ? rootSurface.argv[0] : undefined; - if (rootName === 'validate') { - throw new ConfigError(`${CLI_COMMAND} validate is local-only and is not available in hosted mode.`, LOCAL_ONLY_COMMAND_HELP); + if (rootName === 'validate' || (rootName && opts.installedLocalCommandRoots?.has(rootName))) { + throw new ConfigError(`${CLI_COMMAND} ${rootName} is local-only and is not available in hosted mode.`, LOCAL_ONLY_COMMAND_HELP); } - const externals = rootName && isUnconditionalWebcmdRoot(rootName) ? undefined : opts.externals; + const externals = rootName && isWebcmdOwnedRoot(rootName, opts.installedLocalCommandRoots) + ? undefined + : opts.externals; const credential = await resolveHostedApiKey(config, { credentialStore: opts.credentialStore, env: opts.env, From ec6be02e3f89dec89efae61c2f0c506df1f54c33 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 20:57:44 +0530 Subject: [PATCH 12/13] fix(external): detect explicit executable paths --- src/external.test.ts | 13 ++++++++++++- src/external.ts | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/external.test.ts b/src/external.test.ts index ab031f8c..47606c44 100644 --- a/src/external.test.ts +++ b/src/external.test.ts @@ -23,7 +23,7 @@ vi.mock('node:os', async () => { }); import { spawnSync } from 'node:child_process'; -import { executeExternalCli, formatExternalCliLabel, installExternalCli, parseCommand, type ExternalCliConfig } from './external.js'; +import { executeExternalCli, formatExternalCliLabel, installExternalCli, isBinaryInstalled, parseCommand, type ExternalCliConfig } from './external.js'; import { EXIT_CODES } from './errors.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -81,6 +81,17 @@ describe('formatExternalCliLabel', () => { }); }); +describe('isBinaryInstalled', () => { + it('recognizes an existing explicit executable path without PATH lookup', () => { + mockExecFileSync.mockImplementation(() => { + throw new Error('PATH lookup must not run'); + }); + + expect(isBinaryInstalled(process.execPath)).toBe(true); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); +}); + describe('installExternalCli', () => { const cli: ExternalCliConfig = { name: 'readwise', diff --git a/src/external.ts b/src/external.ts index 4983c748..dd4617e3 100644 --- a/src/external.ts +++ b/src/external.ts @@ -75,6 +75,7 @@ export function loadExternalClis(): ExternalCliConfig[] { } export function isBinaryInstalled(binary: string): boolean { + if (path.isAbsolute(binary) || binary.includes(path.sep)) return fs.existsSync(binary); try { const isWindows = os.platform() === 'win32'; execFileSync(isWindows ? 'where' : 'which', [binary], { stdio: 'ignore' }); From 62c276edd6f17648e017a8361f9a59a1588866b7 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 21:02:17 +0530 Subject: [PATCH 13/13] fix(external): recognize both path separators --- src/external.test.ts | 22 +++++++++++++++++++++- src/external.ts | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/external.test.ts b/src/external.test.ts index 47606c44..a595c36a 100644 --- a/src/external.test.ts +++ b/src/external.test.ts @@ -82,14 +82,34 @@ describe('formatExternalCliLabel', () => { }); describe('isBinaryInstalled', () => { - it('recognizes an existing explicit executable path without PATH lookup', () => { + beforeEach(() => { + mockExecFileSync.mockReset(); mockExecFileSync.mockImplementation(() => { throw new Error('PATH lookup must not run'); }); + }); + it('recognizes an existing explicit executable path without PATH lookup', () => { expect(isBinaryInstalled(process.execPath)).toBe(true); expect(mockExecFileSync).not.toHaveBeenCalled(); }); + + it.each([ + ['forward slash', '/'], + ['backslash', '\\'], + ])('recognizes an existing relative path qualified with a $0', (_name, separator) => { + const directory = fs.mkdtempSync('.webcmd-external-binary-'); + const binary = `${directory}${separator}fixture`; + try { + fs.writeFileSync(binary, ''); + + expect(isBinaryInstalled(binary)).toBe(true); + expect(mockExecFileSync).not.toHaveBeenCalled(); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + fs.rmSync(binary, { force: true }); + } + }); }); describe('installExternalCli', () => { diff --git a/src/external.ts b/src/external.ts index dd4617e3..c3977226 100644 --- a/src/external.ts +++ b/src/external.ts @@ -75,7 +75,7 @@ export function loadExternalClis(): ExternalCliConfig[] { } export function isBinaryInstalled(binary: string): boolean { - if (path.isAbsolute(binary) || binary.includes(path.sep)) return fs.existsSync(binary); + if (path.isAbsolute(binary) || binary.includes('/') || binary.includes('\\')) return fs.existsSync(binary); try { const isWindows = os.platform() === 'win32'; execFileSync(isWindows ? 'where' : 'which', [binary], { stdio: 'ignore' });