import { Cli, z } from "incur"; import { analyzeWebBundle, inspectWebPage, listBrowserTargets, observeWebSession, } from "./application/BrowserObservationService.js"; import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js"; import { logCliCommand } from "./cliLogging.js"; import { parseConfig } from "./config.js"; import { inspectWebPageInputSchema, listBrowserTargetsInputSchema, } from "./domain/browserObservation.js"; import { analyzeWebBundleInputSchema } from "./domain/webBundleAnalysis.js"; import { observeWebSessionInputSchema } from "./domain/browserSession.js"; import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js"; import type { JsonValue } from "./domain/jsonValue.js"; import type { Logger } from "./logger.js"; import { CLI_COMMANDS } from "./cliCommandNames.js"; import { browserPageInspectionOptions } from "./cliObservationOptions.js"; const browserScopeOptions = { allowedOrigins: z .array(z.string().min(1)) .optional() .describe( "Exact origins to observe; defaults to REA_BROWSER_ALLOWED_ORIGINS_JSON", ), approved: z.boolean().default(false).describe("Approve passive observation"), }; const boundedCount = ( subject: string, maximum: number, fallback: number, minimum = 1, ) => z .number() .int() .min(minimum) .max(maximum) .default(fallback) .describe(`Maximum ${subject}`); const boundedBytes = (subject: string, maximum: number, fallback: number) => boundedCount(`${subject} in bytes`, maximum, fallback); /** Register CLI equivalents of the passive browser MCP tools. */ export const registerBrowserCommands = ( cli: ReturnType, logger: Logger, ): void => { registerTargetList(cli, logger); registerPageInspection(cli, logger); registerBundleAnalysis(cli, logger); registerObservationSession(cli, logger); }; const registerTargetList = ( cli: ReturnType, logger: Logger, ): void => { cli.command(CLI_COMMANDS.listBrowserTargets, { description: "List approved pages from a user-owned loopback CDP browser", args: z.object({ endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), }), options: z.object({ ...browserScopeOptions, offset: z .number() .int() .min(0) .default(0) .describe("Zero-based browser-target offset"), limit: boundedCount("browser targets to return", 200, 100), }), run: ({ args, options }) => logCliCommand(logger, "list-browser-targets", async () => { const context = await browserContext(); if (!context.ok) return context.error; const parsed = listBrowserTargetsInputSchema.safeParse({ cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins ?? context.allowedBrowserOrigins, approved: options.approved, offset: options.offset, limit: options.limit, }); if (!parsed.success) return cliError(new AnalysisInputError("list_browser_targets")); const result = await listBrowserTargets( context.provider, context.authority, parsed.data, ); return result.ok ? result.value : cliError(result.error); }), }); }; const registerPageInspection = ( cli: ReturnType, logger: Logger, ): void => { cli.command(CLI_COMMANDS.inspectWebPage, { description: "Passively inspect one approved page through CDP", args: z.object({ endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), targetId: z.string().describe("Target ID from list-browser-targets"), }), options: browserPageInspectionOptions, run: ({ args, options }) => logCliCommand(logger, "inspect-web-page", async () => { const context = await browserContext(); if (!context.ok) return context.error; const parsed = inspectWebPageInputSchema.safeParse({ cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins ?? context.allowedBrowserOrigins, approved: options.approved, target_id: args.targetId, observation_ms: options.observationMs, include_accessibility_text: options.includeAccessibilityText, include_console_text: options.includeConsoleText, console_text_approved: options.consoleTextApproved, include_json_body_shapes: options.includeJsonBodyShapes, json_body_schema_approved: options.jsonBodySchemaApproved, include_websocket_shapes: options.includeWebsocketShapes, websocket_shape_approved: options.websocketShapeApproved, include_script_sources: options.includeScriptSources, include_storage_keys: options.includeStorageKeys, limits: { max_frames: options.maxFrames, max_dom_nodes: options.maxDomNodes, max_ax_nodes: options.maxAxNodes, max_ax_text_field_bytes: options.maxAxTextFieldBytes, max_total_ax_text_bytes: options.maxTotalAxTextBytes, max_scripts: options.maxScripts, max_resources: options.maxResources, max_workers: options.maxWorkers, max_storage_keys: options.maxStorageKeys, max_script_source_bytes: options.maxScriptSourceBytes, max_total_script_source_bytes: options.maxTotalScriptSourceBytes, max_network_events: options.maxNetworkEvents, max_console_events: options.maxConsoleEvents, max_console_text_field_bytes: options.maxConsoleTextFieldBytes, max_total_console_text_bytes: options.maxTotalConsoleTextBytes, max_json_body_bytes: options.maxJsonBodyBytes, max_total_json_body_bytes: options.maxTotalJsonBodyBytes, max_json_shape_nodes: options.maxJsonShapeNodes, max_json_shape_depth: options.maxJsonShapeDepth, max_websocket_events: options.maxWebsocketEvents, max_websocket_shape_bytes: options.maxWebsocketShapeBytes, max_total_websocket_shape_bytes: options.maxTotalWebsocketShapeBytes, }, }); if (!parsed.success) return cliError(new AnalysisInputError("inspect_web_page")); const result = await inspectWebPage( context.provider, context.authority, parsed.data, ); return result.ok ? result.value : cliError(result.error); }), }); }; const registerBundleAnalysis = ( cli: ReturnType, logger: Logger, ): void => { cli.command(CLI_COMMANDS.analyzeWebBundle, { description: "Capture and statically analyze an approved page bundle", args: z.object({ endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), targetId: z.string().describe("Target ID from list-browser-targets"), }), options: z.object({ ...browserScopeOptions, sourceCaptureApproved: z .boolean() .default(false) .describe("Approve capturing bounded script source text"), observationMs: boundedCount( "observation duration in milliseconds", 10_000, 500, 0, ), fetchSourceMaps: z .boolean() .default(false) .describe("Fetch source maps referenced by captured scripts"), sourceMapFetchApproved: z .boolean() .default(false) .describe( "Approve fetching referenced source maps from allowed origins", ), maxFindings: boundedCount("static-analysis findings", 10_000, 1_000), maxAstNodes: boundedCount("JavaScript AST nodes", 2_000_000, 250_000), maxSourceMaps: boundedCount("source maps", 1_000, 100), maxSourceMapBytes: boundedBytes( "one source map", 16 * 1_024 * 1_024, 4 * 1_024 * 1_024, ), maxTotalSourceMapBytes: boundedBytes( "total source maps", 64 * 1_024 * 1_024, 16 * 1_024 * 1_024, ), maxSourceMapMappings: boundedCount( "decoded source-map mappings", 100_000, 10_000, ), maxOriginalSources: boundedCount("original source files", 20_000, 2_000), }), run: ({ args, options }) => logCliCommand(logger, "analyze-web-bundle", async () => { const context = await browserContext(); if (!context.ok) return context.error; const parsed = analyzeWebBundleInputSchema.safeParse({ cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins ?? context.allowedBrowserOrigins, approved: options.approved, target_id: args.targetId, observation_ms: options.observationMs, include_script_sources: true, source_capture_approved: options.sourceCaptureApproved, fetch_source_maps: options.fetchSourceMaps, source_map_fetch_approved: options.sourceMapFetchApproved, analysis_limits: { max_findings: options.maxFindings, max_ast_nodes: options.maxAstNodes, max_source_maps: options.maxSourceMaps, max_source_map_bytes: options.maxSourceMapBytes, max_total_source_map_bytes: options.maxTotalSourceMapBytes, max_source_map_mappings: options.maxSourceMapMappings, max_original_sources: options.maxOriginalSources, }, }); if (!parsed.success) return cliError(new AnalysisInputError("analyze_web_bundle")); const result = await analyzeWebBundle( context.provider, context.authority, parsed.data, ); return result.ok ? result.value : cliError(result.error); }), }); }; const registerObservationSession = ( cli: ReturnType, logger: Logger, ): void => { cli.command(CLI_COMMANDS.observeWebSession, { description: "Observe user-driven page navigation during a bounded window", args: z.object({ endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), targetId: z.string().describe("Target ID from list-browser-targets"), }), options: z.object({ ...browserScopeOptions, observationMs: boundedCount( "observation duration in milliseconds", 60_000, 10_000, ), maxTimelineEvents: boundedCount("timeline events", 20_000, 2_000), }), run: ({ args, options }) => logCliCommand(logger, "observe-web-session", async () => { const context = await browserContext(); if (!context.ok) return context.error; const parsed = observeWebSessionInputSchema.safeParse({ cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins ?? context.allowedBrowserOrigins, target_id: args.targetId, approved: options.approved, observation_ms: options.observationMs, max_timeline_events: options.maxTimelineEvents, }); if (!parsed.success) return cliError(new AnalysisInputError("observe_web_session")); const result = await observeWebSession( context.provider, context.authority, parsed.data, ); return result.ok ? result.value : cliError(result.error); }), }); }; const browserContext = async () => { const config = parseConfig(process.env); if (!config.ok) return { ok: false as const, error: cliError(config.error) }; const authority = await loadConfiguredPermissionAuthority(config.value); if (!authority.ok) return { ok: false as const, error: cliError(authority.error) }; return { ok: true as const, authority: authority.value, provider: new CdpBrowserProvider(), allowedBrowserOrigins: config.value.browserAllowedOrigins, }; }; const cliError = ( error: Parameters[0], ): JsonValue => ({ error: "Browser observation failed", ...projectAnalysisError(error), });