From f8f220d87741279cb686d935f918373ab6fbbbd3 Mon Sep 17 00:00:00 2001 From: ayushsingh82 Date: Sat, 8 Aug 2026 09:12:32 +0530 Subject: [PATCH] feat: add adapter_hints to browser analyze, wire it up as a real command Browser recon now runs on the Playwright-style `browser run` sandbox while adapters stay on the stable IPage/registry API. Nothing bridged the two: an agent doing recon had to manually re-derive strategy choice and adapter shape from raw evidence, with only a prose reminder ("do not paste Playwright code into an adapter") standing between the two contracts. analyze.ts already computed most of what's needed (pattern classification, anti-bot detection, scored network evidence, nearest adapter) via analyzeSite(), but per #217 the CLI wrapper that used to drive it was removed in the browser-run migration, leaving it unreachable from any command. - Add buildAdapterHints()/AdapterHints to analyze.ts: a recommended discovery-time strategy (PUBLIC_API/COOKIE_API/UI_SELECTOR/DOM_STATE/ INTERCEPT), the adapter-compatible path it maps to (browser:false -> func(args) vs browser:true -> func(page,args)), flagged state hazards (anti-bot, auth failures, missing cookies when a cookie strategy is recommended), a pointer to the snapshot tool for selector evidence (not captured by PageSignals), and a fixed do-not-copy-Playwright notice. Wired into AnalyzeReport as `adapter_hints`. - Re-register `webcmd browser analyze --stdin|--file` as a pure JSON-in/ JSON-out command: no live session, no daemon/CDP integration, just scores PageSignals an agent already captured via `browser run` and prints the AnalyzeReport (including adapter_hints). This intentionally avoids the exact bug #217 reported (a required arg with no documented example) by not taking a session at all. - Update site-recon.md with an optional PageSignals-shaped recon script piped into the new command, and SKILL.md's Step 3 to mention it. - IPage is untouched, per the issue's explicit constraint to only extend it when evals prove a missing capability. Fixes #226 --- skills/webcmd-adapter-author/SKILL.md | 2 +- .../references/site-recon.md | 49 ++++++++ src/browser/analyze.test.ts | 90 +++++++++++++ src/browser/analyze.ts | 115 +++++++++++++++++ src/browser/command-catalog.test.ts | 1 + src/cli.test.ts | 118 +++++++++++++++++- src/cli.ts | 45 +++++++ 7 files changed, 417 insertions(+), 3 deletions(-) diff --git a/skills/webcmd-adapter-author/SKILL.md b/skills/webcmd-adapter-author/SKILL.md index 6e4fbbb0..15e5a6d5 100644 --- a/skills/webcmd-adapter-author/SKILL.md +++ b/skills/webcmd-adapter-author/SKILL.md @@ -152,7 +152,7 @@ Check these off step by step: [ ] **Preferred:** use `webcmd browser recon run --stdin` for navigation, readiness, network hints, and page evidence in one Playwright-style program. [ ] Use `webcmd browser recon snapshot --snapshot-mode tree` when structural page evidence is needed. [ ] Use the run result as reconnaissance evidence; do not copy Playwright code into an adapter. - [ ] Choose Pattern A / B / C / D / E. + [ ] Choose Pattern A / B / C / D / E — optionally shape the run result as `PageSignals` and pipe it through `webcmd browser analyze` for an automated pattern classification plus `adapter_hints` (recommended strategy, adapter-compatible path, state hazards). See "Optional: Automated Classification + Adapter Hints" in `site-recon.md`. [ ] 4. API discovery (`api-discovery.md`) by Pattern: [ ] Pattern A -> section 1 network deep read. diff --git a/skills/webcmd-adapter-author/references/site-recon.md b/skills/webcmd-adapter-author/references/site-recon.md index 61070b74..b8f4f5ff 100644 --- a/skills/webcmd-adapter-author/references/site-recon.md +++ b/skills/webcmd-adapter-author/references/site-recon.md @@ -47,6 +47,55 @@ webcmd browser recon snapshot --snapshot-mode tree Use this evidence to choose Pattern A/B/C/D/E. Do not paste the Playwright-style program into the adapter. +### Optional: Automated Classification + Adapter Hints + +To skip manually reading the table below, shape the `browser run` return value as +`PageSignals` and pipe it into `webcmd browser analyze`. This is a pure JSON-in/JSON-out +command — it does not drive a live browser itself, it only scores evidence you already +captured: + +```bash +webcmd browser recon run --stdin <<'JS' > /tmp/signals.json +const networkEntries = []; +page.on('response', async response => { + const contentType = response.headers()['content-type'] || ''; + networkEntries.push({ + url: response.url(), + status: response.status(), + contentType, + bodyPreview: /json|text\/event-stream/i.test(contentType) + ? (await response.text().catch(() => '')).slice(0, 2000) + : null, + }); +}); + +await page.goto(''); +await page.waitForLoadState('domcontentloaded'); +await page.waitForTimeout(1500); + +return { + requestedUrl: '', + finalUrl: page.url(), + title: await page.title(), + cookieNames: (await page.context().cookies()).map(c => c.name), + networkEntries: networkEntries.slice(0, 30), + initialState: await page.evaluate(() => ({ + __INITIAL_STATE__: Boolean(window.__INITIAL_STATE__), + __NUXT__: Boolean(window.__NUXT__), + __NEXT_DATA__: Boolean(window.__NEXT_DATA__), + __APOLLO_STATE__: Boolean(window.__APOLLO_STATE__), + })), +}; +JS +webcmd browser analyze --file /tmp/signals.json +``` + +The report includes `pattern` (A/B/C/D/E with reasoning), `anti_bot`, scored `api_candidates`, +and an `adapter_hints` object with a recommended strategy, the corresponding adapter +signature (`browser:false -> func(args)` vs `browser:true -> func(page,args)`), flagged +state hazards, and a standing reminder that this report — and any Playwright-style code — +is reconnaissance evidence, never adapter source. + ## Existing-Page Diagnosis Use this when the user already has a relevant tab open. List pages, bind the chosen page, diff --git a/src/browser/analyze.test.ts b/src/browser/analyze.test.ts index 7477b161..caffae87 100644 --- a/src/browser/analyze.test.ts +++ b/src/browser/analyze.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect } from 'vitest'; import { analyzeSite, + buildAdapterHints, detectAntiBot, classifyPattern, findNearestAdapter, scoreEndpointEvidence, + scoreNetworkEvidence, type PageSignals, } from './analyze.js'; import type { CliCommand } from '../registry.js'; @@ -260,4 +262,92 @@ describe('analyzeSite', () => { ); expect(report.nearest_adapter?.site).toBe('github'); }); + + it('always includes adapter_hints with the Playwright boundary notice', () => { + const report = analyzeSite(mkSignals(), new Map()); + expect(report.adapter_hints.do_not_copy_playwright_notice).toMatch(/not adapter source/i); + expect(report.adapter_hints.network_evidence).toEqual(report.api_candidates); + }); +}); + +describe('buildAdapterHints', () => { + it('recommends PUBLIC_API for Pattern A with no anti-bot signal', () => { + const signals = mkSignals({ + networkEntries: [ + { url: 'https://x.com/api/a', status: 200, contentType: 'application/json', bodyPreview: '{"items":[{"title":"A","id":"1"}]}' }, + ], + }); + const pattern = classifyPattern(signals); + const antiBot = detectAntiBot(signals); + const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals)); + expect(hints.recommended_strategy).toBe('PUBLIC_API'); + expect(hints.adapter_compatible_path).toMatch(/Strategy\.PUBLIC.*browser:false/); + expect(hints.state_hazards).toEqual([]); + }); + + it('recommends COOKIE_API and flags the hazard for Pattern A behind a WAF', () => { + const signals = mkSignals({ + cookieNames: ['acw_sc__v2'], + networkEntries: [ + { url: 'https://x.com/api/a', status: 200, contentType: 'application/json', bodyPreview: '{"items":[{"title":"A","id":"1"}]}' }, + ], + }); + const pattern = classifyPattern(signals); + const antiBot = detectAntiBot(signals); + const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals)); + expect(hints.recommended_strategy).toBe('COOKIE_API'); + expect(hints.state_hazards.some((h) => /aliyun_waf/i.test(h))).toBe(true); + }); + + it('recommends DOM_STATE for Pattern B', () => { + const signals = mkSignals({ + initialState: { __INITIAL_STATE__: true, __NUXT__: false, __NEXT_DATA__: false, __APOLLO_STATE__: false }, + }); + const pattern = classifyPattern(signals); + const antiBot = detectAntiBot(signals); + const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals)); + expect(hints.recommended_strategy).toBe('DOM_STATE'); + expect(hints.adapter_compatible_path).toMatch(/page\.evaluate/); + }); + + it('recommends COOKIE_API and flags the auth hazard for Pattern D', () => { + const signals = mkSignals({ + networkEntries: [ + { url: 'https://x.com/api/a', status: 401, contentType: 'application/json', bodyPreview: '' }, + { url: 'https://x.com/api/b', status: 403, contentType: 'application/json', bodyPreview: '' }, + ], + }); + const pattern = classifyPattern(signals); + const antiBot = detectAntiBot(signals); + const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals)); + expect(hints.recommended_strategy).toBe('COOKIE_API'); + expect(hints.state_hazards.some((h) => /401\/403/.test(h))).toBe(true); + }); + + it('recommends UI_SELECTOR and points at the snapshot tool for Pattern C', () => { + const signals = mkSignals(); + const pattern = classifyPattern(signals); + const antiBot = detectAntiBot(signals); + const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals)); + expect(pattern.pattern).toBe('C'); + expect(hints.recommended_strategy).toBe('UI_SELECTOR'); + expect(hints.selector_evidence).toMatch(/browser snapshot/); + }); + + it('recommends INTERCEPT and flags the WS hazard for Pattern E', () => { + const signals = mkSignals(); + const pattern = { pattern: 'E' as const, reason: 'WS traffic observed', json_responses: 0, real_data_candidates: 0, auth_failures: 0 }; + const antiBot = detectAntiBot(signals); + const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals)); + expect(hints.recommended_strategy).toBe('INTERCEPT'); + expect(hints.state_hazards.some((h) => /WebSocket/.test(h))).toBe(true); + }); + + it('always carries the fixed do-not-copy-Playwright notice regardless of strategy', () => { + const signals = mkSignals(); + const pattern = classifyPattern(signals); + const antiBot = detectAntiBot(signals); + const hints = buildAdapterHints(signals, pattern, antiBot, scoreNetworkEvidence(signals)); + expect(hints.do_not_copy_playwright_notice).toMatch(/never paste Playwright locators/i); + }); }); diff --git a/src/browser/analyze.ts b/src/browser/analyze.ts index 95d224b5..07545ac1 100644 --- a/src/browser/analyze.ts +++ b/src/browser/analyze.ts @@ -471,6 +471,119 @@ export function findNearestAdapter( }; } +// ── Adapter hints (recon → adapter translation) ───────────────────────────── + +/** + * Discovery-time strategy label, matching the vocabulary of the strategy-note + * template in `references/adapter-template.md` — distinct from the runtime + * `Strategy` enum in `registry.ts`, which `adapter_compatible_path` maps to. + */ +export type RecommendedStrategy = + | 'PUBLIC_API' + | 'COOKIE_API' + | 'UI_SELECTOR' + | 'DOM_STATE' + | 'INTERCEPT'; + +export interface AdapterHints { + recommended_strategy: RecommendedStrategy; + /** How the recommended strategy maps onto the stable adapter API — never Playwright. */ + adapter_compatible_path: string; + /** Same evidence as `AnalyzeReport.api_candidates`, grouped here for a self-contained hint object. */ + network_evidence: EndpointEvidence[]; + /** DOM selectors aren't captured by PageSignals; point at the tool that captures them instead of fabricating evidence. */ + selector_evidence: string; + state_hazards: string[]; + /** Fixed boundary reminder — always present so the hint object stands alone even if only this field is read. */ + do_not_copy_playwright_notice: string; +} + +const DO_NOT_COPY_PLAYWRIGHT_NOTICE = + 'This report and any Playwright-style `browser run` code are reconnaissance evidence, not adapter source. ' + + 'Implement the adapter with the existing IPage/pipeline/Node-fetch APIs (`browser:false -> func(args)` or ' + + '`browser:true -> func(page,args)`); never paste Playwright locators, page.goto, or run() code into an adapter\'s func().'; + +const SELECTOR_EVIDENCE_POINTER = + 'Not captured by this report. For UI_SELECTOR/DOM scraping, run `webcmd browser snapshot --snapshot-mode tree` ' + + 'and record semantic selectors/ARIA roles for the target rows before writing the adapter.'; + +function recommendStrategy( + pattern: PatternVerdict, + antiBot: AntiBotVerdict, +): { strategy: RecommendedStrategy; path: string } { + switch (pattern.pattern) { + case 'D': + return { + strategy: 'COOKIE_API', + path: 'Strategy.COOKIE, browser:true -> func(page,args); read cookies with page.getCookies() and finish with Node-side fetch.', + }; + case 'B': + return { + strategy: 'DOM_STATE', + path: 'browser:true -> func(page,args); read the SSR/hydration global with page.evaluate() — no API call needed.', + }; + case 'A': + return antiBot.detected + ? { + strategy: 'COOKIE_API', + path: 'Strategy.COOKIE, browser:true -> func(page,args); read cookies with page.getCookies() and finish with Node-side fetch.', + } + : { + strategy: 'PUBLIC_API', + path: 'Strategy.PUBLIC, browser:false -> func(args); plain Node-side fetch, no browser context required.', + }; + case 'E': + return { + strategy: 'INTERCEPT', + path: 'Raw WebSocket streams are not supported by adapters — find the underlying HTTP poll/long-poll endpoint and treat it as PUBLIC_API/COOKIE_API instead.', + }; + case 'C': + default: + return { + strategy: 'UI_SELECTOR', + path: 'browser:true -> func(page,args); no API/SSR-state evidence yet, so extract with IPage selectors against the rendered page.', + }; + } +} + +/** + * Translate recon evidence into a structured bridge toward the stable + * adapter API, so agents act on the report instead of re-deriving strategy + * choice by hand or copying Playwright-style `browser run` code into `func`. + * See issue #226. + */ +export function buildAdapterHints( + signals: PageSignals, + pattern: PatternVerdict, + antiBot: AntiBotVerdict, + apiCandidates: EndpointEvidence[], +): AdapterHints { + const { strategy, path } = recommendStrategy(pattern, antiBot); + + const hazards: string[] = []; + if (antiBot.detected) { + hazards.push(`${antiBot.vendor ?? 'unknown'} anti-bot detected: ${antiBot.evidence.join('; ')}`); + } + if (pattern.auth_failures > 0) { + hazards.push(`${pattern.auth_failures} response(s) returned 401/403 — endpoint likely requires an authenticated session`); + } + if (pattern.pattern === 'E') { + hazards.push('WebSocket stream detected — raw WS is not supported by adapters; find the HTTP poll fallback.'); + } + if (signals.cookieNames.length === 0 && strategy === 'COOKIE_API') { + hazards.push('Recommended strategy needs an authenticated session, but no cookies were observed — re-run recon from a signed-in session before picking a strategy.'); + } + + return { + recommended_strategy: strategy, + adapter_compatible_path: path, + network_evidence: apiCandidates, + selector_evidence: SELECTOR_EVIDENCE_POINTER, + state_hazards: hazards, + do_not_copy_playwright_notice: DO_NOT_COPY_PLAYWRIGHT_NOTICE, + }; +} + // ── Top-level assembly ──────────────────────────────────────────────────── export interface AnalyzeReport { @@ -483,6 +596,7 @@ export interface AnalyzeReport { api_candidates: EndpointEvidence[]; nearest_adapter: NearestAdapter | null; recommended_next_step: string; + adapter_hints: AdapterHints; } /** @@ -528,5 +642,6 @@ export function analyzeSite( api_candidates: apiCandidates, nearest_adapter: nearest, recommended_next_step: next, + adapter_hints: buildAdapterHints(signals, pattern, antiBot, apiCandidates), }; } diff --git a/src/browser/command-catalog.test.ts b/src/browser/command-catalog.test.ts index 9e275d14..0f08f3fa 100644 --- a/src/browser/command-catalog.test.ts +++ b/src/browser/command-catalog.test.ts @@ -24,6 +24,7 @@ describe('browserCommandCatalog', () => { expect(browserCommand().commands.map(command => command.name())).toEqual([ 'init', 'verify', + 'analyze', 'tabs', 'bind', 'run', diff --git a/src/cli.test.ts b/src/cli.test.ts index 734ed20e..267bb5b4 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -724,8 +724,8 @@ name: 'search', expect(data.namespace).toBe('browser'); expect(data.command).toBe('webcmd browser'); expect(data.description).toBe('Run Playwright programs against named browser sessions'); - expect(data.command_count).toBe(7); - expect(data.commands.map((cmd: any) => cmd.name)).toEqual(['bind', 'close', 'init', 'run', 'snapshot', 'tabs', 'verify']); + expect(data.command_count).toBe(8); + expect(data.commands.map((cmd: any) => cmd.name)).toEqual(['analyze', 'bind', 'close', 'init', 'run', 'snapshot', 'tabs', 'verify']); // `--session` is now a hidden internal option; user-facing surface is the // positional declared via `.usage()`. Structured help drops // hidden options, so namespace_options shouldn't expose it. @@ -1053,6 +1053,120 @@ describe('resolveSitemapAvailabilityForUrl', () => { }); }); +describe('browser analyze', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + beforeEach(() => { + process.exitCode = undefined; + consoleLogSpy.mockClear(); + }); + + const validSignals = { + requestedUrl: 'https://example.com/', + finalUrl: 'https://example.com/', + cookieNames: [], + networkEntries: [ + { url: 'https://example.com/api/items', status: 200, contentType: 'application/json', bodyPreview: '{"items":[{"title":"A","id":"1"}]}' }, + ], + initialState: { __INITIAL_STATE__: false, __NUXT__: false, __NEXT_DATA__: false, __APOLLO_STATE__: false }, + title: 'Example', + }; + + function withTempFile(contents: string, fn: (filePath: string) => Promise) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-analyze-')); + const filePath = path.join(dir, 'signals.json'); + fs.writeFileSync(filePath, contents, 'utf-8'); + return fn(filePath).finally(() => fs.rmSync(dir, { recursive: true, force: true })); + } + + it('scores PageSignals from --file into a report with adapter_hints', async () => { + await withTempFile(JSON.stringify(validSignals), async (filePath) => { + const program = createProgram('', ''); + await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'analyze', '--file', filePath]); + + expect(process.exitCode).toBeUndefined(); + const output = consoleLogSpy.mock.calls.map((args) => args.join(' ')).join('\n'); + const report = JSON.parse(output); + expect(report.pattern.pattern).toBe('A'); + expect(report.adapter_hints.recommended_strategy).toBe('PUBLIC_API'); + expect(report.adapter_hints.do_not_copy_playwright_notice).toMatch(/not adapter source/i); + }); + }); + + it('reads PageSignals from --stdin', async () => { + const { Readable } = await import('node:stream'); + const originalStdin = process.stdin; + Object.defineProperty(process, 'stdin', { value: Readable.from([JSON.stringify(validSignals)]), configurable: true }); + + try { + const program = createProgram('', ''); + await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'analyze', '--stdin']); + + expect(process.exitCode).toBeUndefined(); + const output = consoleLogSpy.mock.calls.map((args) => args.join(' ')).join('\n'); + const report = JSON.parse(output); + expect(report.pattern.pattern).toBe('A'); + } finally { + Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true }); + } + }); + + it('rejects when neither --stdin nor --file is given', async () => { + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const program = createProgram('', ''); + await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'analyze']); + + expect(process.exitCode).toBe(2); + const errOutput = stderr.mock.calls.map((args) => args.join(' ')).join('\n'); + expect(errOutput).toContain('Provide exactly one of --stdin or --file'); + } finally { + stderr.mockRestore(); + } + }); + + it('rejects when both --stdin and --file are given', async () => { + await withTempFile(JSON.stringify(validSignals), async (filePath) => { + const program = createProgram('', ''); + await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'analyze', '--stdin', '--file', filePath]); + + expect(process.exitCode).toBe(2); + }); + }); + + it('rejects invalid JSON with a usage error', async () => { + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await withTempFile('not json', async (filePath) => { + const program = createProgram('', ''); + await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'analyze', '--file', filePath]); + + expect(process.exitCode).toBe(2); + const errOutput = stderr.mock.calls.map((args) => args.join(' ')).join('\n'); + expect(errOutput).toContain('Could not parse input as JSON'); + }); + } finally { + stderr.mockRestore(); + } + }); + + it('rejects PageSignals missing a required field', async () => { + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await withTempFile(JSON.stringify({ requestedUrl: 'https://example.com/' }), async (filePath) => { + const program = createProgram('', ''); + await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'analyze', '--file', filePath]); + + expect(process.exitCode).toBe(2); + const errOutput = stderr.mock.calls.map((args) => args.join(' ')).join('\n'); + expect(errOutput).toContain('missing required PageSignals field'); + }); + } finally { + stderr.mockRestore(); + } + }); +}); + describe('browser verify', () => { beforeEach(() => { process.exitCode = undefined; diff --git a/src/cli.ts b/src/cli.ts index 5d0828f6..0b32489e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1004,6 +1004,51 @@ cli({ } }); + async function readStdinText(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); + } + + browser.command('analyze') + .description('Score recon evidence captured via `browser run` into a pattern classification and adapter-authoring hints') + .option('--stdin', 'Read PageSignals JSON from stdin') + .option('--file ', 'Read PageSignals JSON from a file') + .action(async (opts: { stdin?: boolean; file?: string }) => { + try { + if (Number(opts.stdin === true) + Number(typeof opts.file === 'string') !== 1) { + throw new ArgumentError( + 'Provide exactly one of --stdin or --file .', + 'Capture PageSignals JSON with a `browser run` recon script (see references/site-recon.md), then pipe or save it for this command.', + ); + } + const raw = opts.stdin === true + ? await readStdinText() + : fs.readFileSync(opts.file as string, 'utf-8'); + + let signals: PageSignals; + try { + signals = JSON.parse(raw) as PageSignals; + } catch (err) { + throw new ArgumentError(`Could not parse input as JSON: ${err instanceof Error ? err.message : String(err)}`); + } + for (const field of ['requestedUrl', 'finalUrl', 'cookieNames', 'networkEntries', 'initialState', 'title'] as const) { + if (!(field in signals)) { + throw new ArgumentError(`Input is missing required PageSignals field "${field}".`, 'See references/site-recon.md for the expected shape.'); + } + } + + const report = analyzeSite(signals, getRegistry()); + console.log(JSON.stringify(report, null, 2)); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + if (err instanceof CliError && err.hint) console.error(`Hint: ${err.hint}`); + process.exitCode = err instanceof CliError ? err.exitCode : EXIT_CODES.GENERIC_ERROR; + } + }); + function rawBrowserAction(fn: (session: string, routing: { contextId?: string; preferredContextId?: string }, opts: Record) => Promise) { return async (opts: Record, command: Command) => { try {