diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 1c8db8f7f..da9bd3a3d 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -5,6 +5,7 @@ on: branches: - main - dev + - feat/agent-mcp-interface-bootstrap paths: - "packages/browseros-agent/**" diff --git a/packages/browseros-agent/.fallowrc.json b/packages/browseros-agent/.fallowrc.json index eba87d59c..b89bc916f 100644 --- a/packages/browseros-agent/.fallowrc.json +++ b/packages/browseros-agent/.fallowrc.json @@ -3,6 +3,7 @@ "entry": [ "apps/server/src/index.ts", "apps/server/src/compiled-bootstrap.ts", + "apps/agent-mcp-interface/src/main.ts", "apps/agent/entrypoints/app/main.tsx", "apps/agent/entrypoints/sidepanel/main.tsx", "apps/agent/entrypoints/background/index.ts", diff --git a/packages/browseros-agent/apps/agent-mcp-interface/biome.json b/packages/browseros-agent/apps/agent-mcp-interface/biome.json new file mode 100644 index 000000000..941e6653d --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/biome.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", + "root": false, + "extends": "//", + "linter": { + "rules": { + "suspicious": { + "noConsole": "error" + }, + "style": { + "noProcessEnv": "error" + } + } + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/package.json b/packages/browseros-agent/apps/agent-mcp-interface/package.json new file mode 100644 index 000000000..41a9e07e5 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/package.json @@ -0,0 +1,43 @@ +{ + "name": "@browseros/agent-mcp-interface", + "type": "module", + "private": true, + "version": "0.0.1", + "description": "Hono backend exposing the BrowserOS v2 agent surface: typed JSON API + per-agent MCP route mux. File-based config under /mcp-interface/.", + "exports": { + "./server": { + "types": "./src/server.ts", + "default": null + }, + "./cockpit": { + "types": "./src/cockpit.ts", + "default": "./src/cockpit.ts" + }, + "./shared/port": { + "types": "./src/shared/port.ts", + "default": "./src/shared/port.ts" + } + }, + "scripts": { + "start": "bun --watch src/main.ts", + "start:ci": "bun src/main.ts", + "test": "bun test", + "typecheck": "tsc --noEmit", + "lint": "bunx biome check", + "lint:fix": "bunx biome check --write --unsafe" + }, + "dependencies": { + "@browseros/server": "workspace:*", + "@browseros/shared": "workspace:*", + "@hono/zod-validator": "^0.8.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "agent-mcp-manager": "^0.0.1", + "hono": "^4.12.3", + "nanoid": "^5.1.11", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/bun": "^1.3.5", + "typescript": "^5.9.2" + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/cockpit.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/cockpit.ts new file mode 100644 index 000000000..4c1307540 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/cockpit.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * The cockpit's "mount me inside apps/server" entry point. + * + * `createCockpitRoutes` is the function `apps/server`'s + * `createHttpServer` calls to splice the cockpit's `/agents`, + * `/site-rules`, `/permissions`, `/mcp/:slug`, and `/system` routes + * into the parent Hono runtime under a `/cockpit` prefix. The + * factory: + * + * 1. Wires the cockpit's process-wide singletons (the live + * `BrowserSession` for tool dispatch, and the base URL that + * `buildMcpUrl` uses to compose the per-agent endpoints). + * 2. Runs a one-shot `migrateMcpUrls` sweep over the profile + * directory so any URLs saved before the runtime merge get + * rewritten to the new shape; harness configs get re-installed + * automatically as part of the migration. + * 3. Returns the cockpit's Hono `app` so the parent can mount it + * via `.route('/cockpit', cockpitApp)`. + * + * The standalone `src/main.ts` still works for tests + solo dev + * (the BrowserSession stays null in that path, so tool dispatches + * short-circuit with "session not connected"). Production goes + * through `createCockpitRoutes`. + */ + +import type { BrowserSession } from '@browseros/server/browser/core/session' +import { setBrowserSession } from './lib/browser-session' +import { logger } from './lib/logger' +import { migrateMcpUrls } from './lib/migrate-mcp-urls' +import { setLocalServerUrl } from './local-server-url' +import app from './server' + +export interface CockpitDeps { + /** Live BrowserSession from the parent runtime. Tool dispatches bind to this. */ + browserSession: BrowserSession + /** The port `apps/server` bound to; used to compose MCP URLs the harness can reach. */ + serverPort: number + /** + * Mount prefix the cockpit's app is being routed under. Embedded in + * `buildMcpUrl` so per-agent URLs match the actual routable shape. + * Defaults to `/cockpit`; passing `''` removes the prefix entirely + * for testing. + */ + mountPrefix?: string +} + +export function createCockpitRoutes(deps: CockpitDeps): typeof app { + const prefix = deps.mountPrefix ?? '/cockpit' + setBrowserSession(deps.browserSession) + setLocalServerUrl(`http://127.0.0.1:${deps.serverPort}${prefix}`) + // Fire the migration in the background; one bad profile must not + // block server startup. Result is logged so an operator can see + // how many rows moved. + void migrateMcpUrls(buildMcpUrlFromPort(deps.serverPort, prefix)) + .then((result) => + logger.info('mcpUrl migration finished', { + migrated: result.migrated, + skipped: result.skipped, + failed: result.failed, + }), + ) + .catch((err: unknown) => + // `migrateMcpUrls` guards per-profile errors, but `listFiles` at its + // start can still throw (EACCES, ENOTDIR, etc.). Without this `.catch` + // the rejection lands as an unhandled promise rejection that Bun + // discards silently. Logging keeps the failure visible to operators + // without preventing the rest of the cockpit from coming up. + logger.error('mcpUrl migration failed unexpectedly', { + error: err instanceof Error ? err.message : String(err), + }), + ) + return app +} + +function buildMcpUrlFromPort( + serverPort: number, + prefix: string, +): (slug: string) => string { + return (slug: string) => `http://127.0.0.1:${serverPort}${prefix}/mcp/${slug}` +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/env.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/env.ts new file mode 100644 index 000000000..7876b98df --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/env.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Single chokepoint for env reads. Centralising here keeps the rest + * of the source free of process.env access and lets biome's + * noProcessEnv rule stay on at error level for every other file. + */ + +import { COCKPIT_CDP_PORT_DEFAULT, PROD_API_PORT } from './shared/port' + +function readPort(): number { + // biome-ignore lint/style/noProcessEnv: env.ts is the sanctioned env-reader for the package + const raw = process.env.BROWSEROS_AGENT_MCP_INTERFACE_PORT + if (!raw) return PROD_API_PORT + const parsed = Number(raw) + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) { + return PROD_API_PORT + } + return parsed +} + +/** + * Port the cockpit dials when attaching to the BrowserOS Chromium + * over CDP. Default lives in IANA's dynamic / private range so it + * cannot collide with a registered service; the env override is the + * bridge until the BrowserOS browser shell defaults its DevTools + * port to the same value. + */ +function readCdpPort(): number { + // biome-ignore lint/style/noProcessEnv: env.ts is the sanctioned env-reader for the package + const raw = process.env.BROWSEROS_COCKPIT_CDP_PORT + if (!raw) return COCKPIT_CDP_PORT_DEFAULT + const parsed = Number(raw) + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { + return COCKPIT_CDP_PORT_DEFAULT + } + return parsed +} + +function readBrowserosDirOverride(): string | undefined { + // biome-ignore lint/style/noProcessEnv: env.ts is the sanctioned env-reader for the package + const raw = process.env.BROWSEROS_DIR?.trim() + return raw && raw.length > 0 ? raw : undefined +} + +function readIsDevelopment(): boolean { + // biome-ignore lint/style/noProcessEnv: env.ts is the sanctioned env-reader for the package + return process.env.NODE_ENV === 'development' +} + +/** + * Reads happen once at module load. Tests that need different values + * mutate this object before importing the rest of the source graph; + * production code treats it as immutable. + */ +export const env = { + port: readPort(), + cdpPort: readCdpPort(), + browserosDirOverride: readBrowserosDirOverride(), + isDevelopment: readIsDevelopment(), +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/approval-catalog.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/approval-catalog.ts new file mode 100644 index 000000000..bfab9e5d9 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/approval-catalog.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * System-wide default approval catalog. This is the source for + * `GET /permissions/catalog` and the fallback `permissions.check()` + * uses when neither a site rule nor an agent verdict applies. + * + * Keep in sync with the UI's local catalog at + * apps/agent-mcp-ui/screens/new-agent/new-agent.schemas.ts + * (`APPROVAL_CATEGORIES`). The UI keeps its own copy as the + * fetch-failure fallback path for the Permissions tab; this server + * copy is the source of truth at the wire boundary. + */ + +import { z } from 'zod' + +export const approvalVerdictEnum = z.enum(['Auto', 'Ask', 'Block']) +export type ApprovalVerdict = z.infer + +export const approvalCategorySchema = z.object({ + id: z.string(), + name: z.string(), + defaultVerdict: approvalVerdictEnum, + allowAuto: z.boolean(), +}) +export type ApprovalCategory = z.infer + +export const APPROVAL_CATEGORIES: readonly ApprovalCategory[] = [ + { + id: 'submit', + name: 'Submit / send / post', + defaultVerdict: 'Ask', + allowAuto: true, + }, + { + id: 'payment', + name: 'Payments & checkout', + defaultVerdict: 'Block', + allowAuto: false, + }, + { + id: 'delete', + name: 'Delete / destructive', + defaultVerdict: 'Ask', + allowAuto: true, + }, + { id: 'upload', name: 'File upload', defaultVerdict: 'Ask', allowAuto: true }, + { + id: 'navigate', + name: 'Navigate to a new site', + defaultVerdict: 'Ask', + allowAuto: true, + }, + { + id: 'input', + name: 'Click & type', + defaultVerdict: 'Auto', + allowAuto: true, + }, +] diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/async-mutex.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/async-mutex.ts new file mode 100644 index 000000000..5f69d0ec1 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/async-mutex.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * In-process async mutex. Tasks queued via `run` execute one at a + * time in submission order; subsequent tasks always run, regardless + * of whether the previous one resolved or rejected. + * + * Used by the agents service to serialise slug-mutating operations + * (create, update, regenerateMcpUrl) so the read-snapshot → compute + * → write pattern cannot race against itself. The interface server + * is a single-process loopback bind so per-process serialisation is + * the right granularity; multi-process scaling would warrant a + * filesystem-level guard (O_EXCL on a lockfile) instead. + */ + +export class AsyncMutex { + private chain: Promise = Promise.resolve() + + run(task: () => Promise): Promise { + // Swallow any previous rejection so the next task always runs; + // the caller of the prior task already saw the rejection on its + // own promise. + const next = this.chain.catch(() => undefined).then(task) + this.chain = next.catch(() => undefined) + return next + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/browser-bootstrap.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/browser-bootstrap.ts new file mode 100644 index 000000000..fe7f90b6d --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/browser-bootstrap.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * CDP attach helper for the cockpit's standalone runtime. + * + * Mirrors how `apps/server` connects to the BrowserOS Chromium: + * instantiate a `CdpBackend` against the configured port, dial, wrap + * the connection in a `Browser`, and hand the resulting + * `BrowserSession` back so `main.ts` can pin it onto the cockpit's + * process-wide singleton. + * + * `exitOnReconnectFailure: false` is deliberate. apps/server runs as + * a child of the BrowserOS browser shell, so exiting on a dropped + * CDP connection is the right escalation; the parent restarts it. + * The standalone cockpit is user-owned — a transient CDP drop must + * degrade to "session not connected" until BrowserOS is back up, + * not kill the process and lose the UI tab. + * + * Soft fail at boot: if BrowserOS is not running on the configured + * port at startup, return `null` and log a warning. The cockpit + * keeps serving the UI, the profile CRUD, the harness installs, and + * `tools/list`; only `tools/call` short-circuits with the existing + * "browser session not connected" wire shape. Restart the cockpit + * after BrowserOS is up to reattach. + */ + +import { Browser } from '@browseros/server/browser' +import { CdpBackend } from '@browseros/server/browser/backends/cdp' +import type { BrowserSession } from '@browseros/server/browser/core/session' +import { env } from '../env' +import { logger } from './logger' + +export interface BrowserBootstrap { + session: BrowserSession + disconnect(): Promise +} + +/** + * Minimal seam every test needs: a CDP-like value with `connect` and + * `disconnect` methods, so the unit test can supply a stub without + * touching the network. The runtime path passes a real `CdpBackend`. + */ +export interface CdpClient { + connect(): Promise + disconnect(): Promise +} + +/** + * Test seam for swapping out the CDP attach machinery as a single + * unit. `cdpFactory` and `buildSession` are deliberately bundled here + * rather than exposed as two independent optional overrides: the + * default `buildSession` casts its argument to a real `CdpBackend` + * before instantiating `Browser`, so mixing a stub `cdpFactory` with + * the default `buildSession` would compile but blow up at the first + * `Browser` call. Callers either pass no `inject` (production) or + * pass both factories (tests). + */ +export interface BrowserBootstrapInjection { + cdpFactory: (port: number) => CdpClient + buildSession: (cdp: CdpClient) => BrowserSession +} + +export interface BrowserBootstrapDeps { + /** Test-only: replace the entire CDP attach machinery with stubs. */ + inject?: BrowserBootstrapInjection +} + +const defaultInjection: BrowserBootstrapInjection = { + cdpFactory: (port) => new CdpBackend({ port, exitOnReconnectFailure: false }), + buildSession: (cdp) => new Browser(cdp as unknown as CdpBackend).session, +} + +export async function bootstrapBrowserosBrowser( + deps: BrowserBootstrapDeps = {}, +): Promise { + const { cdpFactory, buildSession } = deps.inject ?? defaultInjection + const port = env.cdpPort + const cdp = cdpFactory(port) + try { + await cdp.connect() + } catch (err) { + logger.warn( + 'browseros browser unreachable on cdp port; cockpit will boot without a session', + { + port, + error: err instanceof Error ? err.message : String(err), + }, + ) + return null + } + const session = buildSession(cdp) + return { + session, + disconnect: async () => { + try { + await cdp.disconnect() + } catch (err) { + logger.warn('cdp disconnect failed', { + error: err instanceof Error ? err.message : String(err), + }) + } + }, + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/browser-session.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/browser-session.ts new file mode 100644 index 000000000..5ad2b9fbe --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/browser-session.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Process-wide accessor for the live `BrowserSession` the cockpit + * binds tool dispatches against. Today the cockpit module runs in + * its own Bun process and the session stays null; tool dispatches + * short-circuit with a structured "browser session not connected" + * error so the wire shape is honest about the gap. + * + * The runtime merge step wires the real session at startup by + * calling `setBrowserSession(...)` once apps/server has constructed + * its CDP connection. Tests use the same setter to inject a stub. + */ + +import type { BrowserSession } from '@browseros/server/browser/core/session' + +let session: BrowserSession | null = null + +export function getBrowserSession(): BrowserSession | null { + return session +} + +export function setBrowserSession(next: BrowserSession | null): void { + session = next +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/browseros-dir.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/browseros-dir.ts new file mode 100644 index 000000000..0be68ef09 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/browseros-dir.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Resolves the on-disk directory the interface server reads + writes + * under. Path logic intentionally mirrors `apps/server`'s own + * resolver so a user pointing both servers at the same machine sees + * consistent file locations; env reads stay scoped per package. + * + * Order of preference: + * 1. `BROWSEROS_DIR` env override (read once via `env.ts`). + * 2. `/.browseros-dev` when `NODE_ENV === 'development'`. + * 3. `/.browseros` otherwise. + * + * The interface package writes everything under + * `/mcp-interface/` so other BrowserOS components + * (server, CLI) keep their own subtrees untouched. + */ + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { PATHS } from '@browseros/shared/constants/paths' +import { env } from '../env' + +const INTERFACE_SUBDIR = 'mcp-interface' + +export function getBrowserosDir(): string { + if (env.browserosDirOverride) return env.browserosDirOverride + const dirName = env.isDevelopment + ? PATHS.DEV_BROWSEROS_DIR_NAME + : PATHS.BROWSEROS_DIR_NAME + return join(homedir(), dirName) +} + +/** `/mcp-interface`, the root for this package's files. */ +export function getInterfaceDir(): string { + return join(getBrowserosDir(), INTERFACE_SUBDIR) +} + +/** Convenience: any relative path resolved against the interface root. */ +export function resolveInterfacePath(...segments: string[]): string { + return join(getInterfaceDir(), ...segments) +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/errors.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/errors.ts new file mode 100644 index 000000000..71c9d89fe --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/errors.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Server-side error types and the JSON shape the routes return on + * failure. The future UI consumes these via parseResponse: every + * non-OK response carries `{ error: string }` and the client throws + * a typed ApiError with `.status` and `.body` attached. Discriminated + * `{ success: false }` unions are deliberately avoided so callers can + * always trust the happy-path return type. + */ + +export class HttpError extends Error { + readonly status: number + + constructor(status: number, message: string) { + super(message) + this.status = status + this.name = 'HttpError' + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/logger.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/logger.ts new file mode 100644 index 000000000..30c171ae0 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/logger.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Structured JSON logger. Writes one event per line to stderr so + * downstream log shippers can `tail -F` without competing with + * stdout traffic. The shape matches @browseros/server's pino output + * (level, time, msg, plus arbitrary structured fields) so existing + * log views render both producers identically. + */ + +type LogLevel = 'debug' | 'info' | 'warn' | 'error' + +const LEVEL_PRIORITY: Record = { + debug: 20, + info: 30, + warn: 40, + error: 50, +} + +function write(level: LogLevel, msg: string, fields?: Record) { + const event = { + level: LEVEL_PRIORITY[level], + time: Date.now(), + msg, + ...fields, + } + // biome-ignore lint/suspicious/noConsole: logger is the sanctioned console wrapper for the package + console.error(JSON.stringify(event)) +} + +export const logger = { + debug: (msg: string, fields?: Record) => + write('debug', msg, fields), + info: (msg: string, fields?: Record) => + write('info', msg, fields), + warn: (msg: string, fields?: Record) => + write('warn', msg, fields), + error: (msg: string, fields?: Record) => + write('error', msg, fields), +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/match-domain.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/match-domain.ts new file mode 100644 index 000000000..4341be01a --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/match-domain.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Glob matcher for site-rule domain patterns. Patterns the user can + * configure today: + * + * stripe.com exact match (case insensitive) + * *.example.com any subdomain (must have at least one label + * before .example.com; "example.com" does NOT + * match) + * admin.* any domain that starts with "admin."; "admin." + * alone does not match (we require at least one + * char after the dot) + * * matches every non-empty domain + * + * Same matcher is reused by Phase 3's executor pre-flight and Phase 6's + * grants short-circuit, so the semantics live in one place and the + * unit tests pin every shape. + */ + +const REGEX_META = /[.+?^${}()|[\]\\]/g + +export function matchDomain(pattern: string, domain: string): boolean { + if (!pattern || !domain) return false + const lowerPattern = pattern.toLowerCase() + const lowerDomain = domain.toLowerCase() + // Bare `*` is a hot path: matches any non-empty domain. + if (lowerPattern === '*') return lowerDomain.length > 0 + // Translate the glob to a regex by escaping every metacharacter + // EXCEPT `*`, then replacing each `*` with `.+`. `.+` (not `.*`) so + // `*.foo.com` requires at least one label before `.foo.com`. + const regexSource = lowerPattern + .split('*') + .map((part) => part.replace(REGEX_META, '\\$&')) + .join('.+') + return new RegExp(`^${regexSource}$`).test(lowerDomain) +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/mcp-manager.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/mcp-manager.ts new file mode 100644 index 000000000..4b1654c7d --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/mcp-manager.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Singleton accessor for `agent-mcp-manager`. Manifest lives at + * `/mcp-interface/mcp-manager` so the per-cockpit- + * agent server entries stay isolated from the BrowserOS-wide entry + * `apps/server` manages under `/mcp-manager`. + * + * The library writes config to the user's per-harness MCP file + * (e.g. Claude Desktop's `~/Library/Application Support/Claude/ + * claude_desktop_config.json`, Cursor's `~/.cursor/mcp.json`). + * Scope is always 'system' here since cockpit agents are user-wide. + */ + +import { join } from 'node:path' +import { createMcpManager, type McpManager } from 'agent-mcp-manager' +import { getInterfaceDir } from './browseros-dir' + +let cached: McpManager | null = null + +export function getMcpManager(): McpManager { + if (!cached) { + cached = createMcpManager({ + workspaceDir: join(getInterfaceDir(), 'mcp-manager'), + scope: 'system', + }) + } + return cached +} + +/** Test seam: drop the cached manager so the next caller rebuilds it. */ +export function resetMcpManagerForTesting(): void { + cached = null +} + +/** Test seam: inject a stub manager. */ +export function setMcpManagerForTesting(stub: McpManager): void { + cached = stub +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/migrate-mcp-urls.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/migrate-mcp-urls.ts new file mode 100644 index 000000000..99c28b247 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/migrate-mcp-urls.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * One-shot migration that runs on startup of the merged runtime. + * + * The cockpit moved from binding its own port (9200) to mounting + * inside `@browseros/server`'s HTTP runtime under a `/cockpit` + * prefix. Every profile saved before this change carries an + * `mcpUrl` like `http://127.0.0.1:9200/mcp/` which now 404s, + * and every harness config row written by `agent-mcp-manager` + * points at the same dead URL. The migration walks the profile + * directory, rewrites `mcpUrl` to the new `buildMcpUrl(slug)` + * shape, and re-installs the harness entry so it picks up the new + * value. + * + * Failures are logged per-profile; one bad file does not abort the + * sweep. The migration is idempotent: a second run is a no-op once + * every URL has been refreshed. + */ + +import { + type StoredAgentProfile, + storedAgentProfileSchema, +} from '../routes/agents/schemas' +import { installForAgent, uninstallForAgent } from '../services/harness-install' +import { logger } from './logger' +import { listFiles, readJson, writeJson } from './storage' + +const AGENTS_SUBDIR = 'agents' + +export async function migrateMcpUrls( + buildMcpUrl: (slug: string) => string, +): Promise<{ migrated: number; skipped: number; failed: number }> { + let migrated = 0 + let skipped = 0 + let failed = 0 + const names = await listFiles(AGENTS_SUBDIR) + for (const name of names) { + const file = `${AGENTS_SUBDIR}/${name}` + try { + const profile = await readJson(file, storedAgentProfileSchema) + const next = buildMcpUrl(profile.slug) + if (profile.mcpUrl === next) { + skipped++ + continue + } + const updated: StoredAgentProfile = { ...profile, mcpUrl: next } + await writeJson(file, updated, storedAgentProfileSchema) + // Drop the stale harness entry first, then install the new + // URL. The uninstall is wrapped in its own try/catch so a + // throw here (e.g. the user removed the entry by hand and a + // future agent-mcp-manager build escalates that to an + // exception) does NOT abort the install. Without this + // isolation, the profile JSON would carry the new URL while + // the harness config still points at the dead old one, and + // the next migration pass would skip the row as "already + // migrated". + try { + await uninstallForAgent({ + slug: profile.slug, + harness: profile.harness, + }) + } catch (uninstallErr) { + logger.warn('migration uninstall step threw; continuing install', { + file, + slug: profile.slug, + error: + uninstallErr instanceof Error + ? uninstallErr.message + : String(uninstallErr), + }) + } + await installForAgent({ + slug: updated.slug, + mcpUrl: updated.mcpUrl, + harness: updated.harness, + }) + migrated++ + logger.info('migrated cockpit mcpUrl after runtime merge', { + slug: profile.slug, + from: profile.mcpUrl, + to: next, + }) + } catch (err) { + failed++ + logger.warn('failed to migrate cockpit profile mcpUrl', { + file, + error: err instanceof Error ? err.message : String(err), + }) + } + } + return { migrated, skipped, failed } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/slug.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/slug.ts new file mode 100644 index 000000000..bd3eeacbc --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/slug.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Slug helpers, mirrored from the UI's wizard so the values the user + * sees in the wizard preview match the slugs the server actually + * persists. Keeping the logic identical (lowercase, non-alphanum + * runs collapse to `-`, trim leading/trailing `-`, fall back to + * `agent`) means we can rename the wizard preview into a stale-state + * indicator instead of a "what the server will pick" guess. + */ + +const MAX_COLLISION_SUFFIX = 99 + +export function toSlug(input: string): string { + const cleaned = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + return cleaned || 'agent' +} + +/** + * Returns a slug not present in `existing`. Appends `-2`, `-3`, ..., + * up to MAX_COLLISION_SUFFIX. Throws if even the highest suffix is + * taken (vanishingly unlikely; we surface 409 in the route layer if + * it ever fires). + */ +export function uniqueSlug( + base: string, + existing: ReadonlySet, +): string { + if (!existing.has(base)) return base + for (let i = 2; i <= MAX_COLLISION_SUFFIX; i++) { + const candidate = `${base}-${i}` + if (!existing.has(candidate)) return candidate + } + throw new Error(`slug-collision-exhausted: ${base}`) +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/storage.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/storage.ts new file mode 100644 index 000000000..dd0a15deb --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/storage.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * File-backed JSON storage under the interface root. Every read and + * write goes through a zod schema so on-disk shapes can't drift + * silently. Writes are atomic via a `.tmp` -> rename swap so a + * crash mid-write leaves either the prior contents or nothing at all, + * never a half-written file. + * + * The relative path argument is always evaluated against + * `getInterfaceDir()`; callers cannot reach outside the interface + * root. Absolute paths or `..` segments throw `StorageInvalidPathError` + * so a stray join doesn't accidentally escape. + */ + +import { + mkdir, + readdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises' +import { dirname, isAbsolute, normalize, sep } from 'node:path' +import type { ZodType } from 'zod' +import { resolveInterfacePath } from './browseros-dir' + +export class StorageNotFoundError extends Error { + readonly relPath: string + constructor(relPath: string) { + super(`storage: file not found at ${relPath}`) + this.name = 'StorageNotFoundError' + this.relPath = relPath + } +} + +export class StorageCorruptError extends Error { + readonly relPath: string + constructor(relPath: string, cause: unknown) { + super(`storage: invalid contents at ${relPath}`, { cause }) + this.name = 'StorageCorruptError' + this.relPath = relPath + } +} + +export class StorageInvalidPathError extends Error { + readonly relPath: string + constructor(relPath: string) { + super(`storage: relative path escapes the interface root: ${relPath}`) + this.name = 'StorageInvalidPathError' + this.relPath = relPath + } +} + +function guardRelativePath(relPath: string): void { + if (isAbsolute(relPath)) throw new StorageInvalidPathError(relPath) + // Inspect the raw input first: `normalize` collapses `agents/../config.json` + // to `config.json`, which would silently escape the intended + // subdirectory while still passing the rooted-prefix check below. + // Reject any `..` segment in the input. + if (relPath.split(/[\\/]/).includes('..')) { + throw new StorageInvalidPathError(relPath) + } + const normalized = normalize(relPath) + if (normalized.startsWith('..') || normalized.split(sep).includes('..')) { + throw new StorageInvalidPathError(relPath) + } +} + +async function ensureParentDir(absolutePath: string): Promise { + await mkdir(dirname(absolutePath), { recursive: true }) +} + +function isFsError(err: unknown, code: string): boolean { + return ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as { code?: unknown }).code === code + ) +} + +export async function ensureDir(relDir: string): Promise { + guardRelativePath(relDir) + await mkdir(resolveInterfacePath(relDir), { recursive: true }) +} + +export async function readJson( + relPath: string, + schema: ZodType, +): Promise { + guardRelativePath(relPath) + const abs = resolveInterfacePath(relPath) + let raw: string + try { + raw = await readFile(abs, 'utf8') + } catch (err) { + if (isFsError(err, 'ENOENT')) throw new StorageNotFoundError(relPath) + throw err + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (err) { + throw new StorageCorruptError(relPath, err) + } + const result = schema.safeParse(parsed) + if (!result.success) throw new StorageCorruptError(relPath, result.error) + return result.data +} + +export async function writeJson( + relPath: string, + value: T, + schema: ZodType, +): Promise { + guardRelativePath(relPath) + const parseResult = schema.safeParse(value) + if (!parseResult.success) + throw new StorageCorruptError(relPath, parseResult.error) + const abs = resolveInterfacePath(relPath) + await ensureParentDir(abs) + const tmp = `${abs}.tmp` + await writeFile(tmp, JSON.stringify(parseResult.data, null, 2), 'utf8') + await rename(tmp, abs) +} + +export async function removeFile(relPath: string): Promise { + guardRelativePath(relPath) + try { + await rm(resolveInterfacePath(relPath)) + return true + } catch (err) { + if (isFsError(err, 'ENOENT')) return false + throw err + } +} + +/** + * Returns file names (not paths) in the directory, filtered to + * `.json` by default. Missing directories resolve to `[]` rather than + * throwing; that matches the "first-run, nothing saved yet" UX. + */ +export async function listFiles( + relDir: string, + options: { extension?: string } = {}, +): Promise { + guardRelativePath(relDir) + const extension = options.extension ?? '.json' + try { + const entries = await readdir(resolveInterfacePath(relDir), { + withFileTypes: true, + }) + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) + .map((entry) => entry.name) + } catch (err) { + if (isFsError(err, 'ENOENT')) return [] + throw err + } +} + +export async function fileExists(relPath: string): Promise { + guardRelativePath(relPath) + try { + await readFile(resolveInterfacePath(relPath), 'utf8') + return true + } catch (err) { + if (isFsError(err, 'ENOENT')) return false + throw err + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/extract-page-id.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/extract-page-id.ts new file mode 100644 index 000000000..5fc3b31f6 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/extract-page-id.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Pulls the `page` argument out of a browser-tool dispatch so the + * cockpit's tab-activity registry can attribute the call to a tab. + * Tools without a `page` parameter (`tab_groups`, `windows`, `run`) + * always yield null. Tools that accept it optionally (`tabs` action + * variants like `list` vs `close`) yield null when the caller omits + * it. Non-integer / non-positive values are rejected to keep the + * registry from holding garbage keys. + */ + +const TOOLS_WITH_PAGE: ReadonlySet = new Set([ + 'act', + 'diff', + 'download', + 'evaluate', + 'grep', + 'navigate', + 'pdf', + 'read', + 'screenshot', + 'snapshot', + 'tabs', + 'upload', + 'wait', +]) + +export function extractPageId( + toolName: string, + rawArgs: unknown, +): number | null { + if (!TOOLS_WITH_PAGE.has(toolName)) return null + if (!rawArgs || typeof rawArgs !== 'object') return null + const page = (rawArgs as { page?: unknown }).page + if (typeof page !== 'number') return null + if (!Number.isInteger(page) || page < 1) return null + return page +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/index.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/index.ts new file mode 100644 index 000000000..a1648020f --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/index.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Process-wide singleton registry. Bound to the same + * `getBrowserSession` accessor the rest of the cockpit uses so the + * registry sees the same `PageManager` instance the tool dispatches + * write to. + */ + +import { getBrowserSession } from '../browser-session' +import { createTabActivityRegistry, type TabActivityRegistry } from './registry' + +export const tabActivityRegistry: TabActivityRegistry = + createTabActivityRegistry({ getSession: getBrowserSession }) + +export { extractPageId } from './extract-page-id' +export type { TabActivityRecord, TabActivityRegistry } from './registry' +export { ACTIVE_WINDOW_MS, createTabActivityRegistry } from './registry' diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/registry.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/registry.ts new file mode 100644 index 000000000..e63a27f5c --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/lib/tab-activity/registry.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * In-memory registry mapping a stable CDP target id to the most + * recent agent-tool dispatch that touched it. The cockpit's + * `mcp/register.ts` wrapper writes a record after every successful + * `executeTool` call; the homepage polls `GET /tabs/activity` to + * render the live view. + * + * `status` is derived at read time: a record is `active` when the + * last tool fired within `ACTIVE_WINDOW_MS`, otherwise `idle`. No + * background timers. Records whose underlying tab has closed are + * evicted lazily on the next `snapshot()` read; we detect that by + * looking up `pageId` on the live `PageManager` and confirming the + * targetId still matches (pageIds are reused after a tab closes). + */ + +import type { BrowserSession } from '@browseros/server/browser/core/session' + +export interface TabActivityRecord { + targetId: string + pageId: number + url: string + title: string + agentId: string + slug: string + lastToolAt: number + lastToolName: string + status: 'active' | 'idle' +} + +export const ACTIVE_WINDOW_MS = 5000 + +export interface RegistryDeps { + getSession(): BrowserSession | null + now?: () => number +} + +interface RawRecord { + targetId: string + pageId: number + agentId: string + slug: string + lastToolAt: number + lastToolName: string +} + +export interface TabActivityRegistry { + recordTool(input: { + agentId: string + slug: string + pageId: number + targetId: string + toolName: string + }): void + snapshot(): TabActivityRecord[] + // Test-only escape hatches; let unit tests assert eviction and + // restore isolation without mocking BrowserSession internals. The + // singleton lives across the whole test run, so explicit clearing + // is the only safe way to keep `afterEach` honest. + size(): number + clear(): void +} + +export function createTabActivityRegistry( + deps: RegistryDeps, +): TabActivityRegistry { + const records = new Map() + const now = deps.now ?? (() => Date.now()) + + return { + recordTool(input) { + records.set(input.targetId, { + targetId: input.targetId, + pageId: input.pageId, + agentId: input.agentId, + slug: input.slug, + lastToolAt: now(), + lastToolName: input.toolName, + }) + }, + snapshot() { + const session = deps.getSession() + if (!session) return [] + const out: TabActivityRecord[] = [] + const t = now() + for (const [targetId, raw] of records) { + const live = session.pages.getInfo(raw.pageId) + // PageManager reuses pageId after a tab closes; the targetId + // is the stable identity. If they no longer match, the + // original tab is gone (the pageId may now belong to a + // different tab). + if (!live || live.targetId !== targetId) { + records.delete(targetId) + continue + } + out.push({ + targetId: raw.targetId, + pageId: raw.pageId, + url: live.url, + title: live.title, + agentId: raw.agentId, + slug: raw.slug, + lastToolAt: raw.lastToolAt, + lastToolName: raw.lastToolName, + status: t - raw.lastToolAt < ACTIVE_WINDOW_MS ? 'active' : 'idle', + }) + } + return out.sort((a, b) => b.lastToolAt - a.lastToolAt) + }, + size() { + return records.size + }, + clear() { + records.clear() + }, + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/local-server-url.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/local-server-url.ts new file mode 100644 index 000000000..476c96afd --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/local-server-url.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Module-local singleton holding the URL the Hono server bound to. + * Set once by main.ts after Bun.serve resolves; read by anything that + * needs to compose URLs reachable from inside the same process (e.g. + * the future per-agent MCP route mux will embed this in mcpServers + * configs handed to spawned host agents). + * + * Plain mutable string rather than a hook/store because writer and + * reader live in the same Bun process and the value is written + * exactly once at boot. + */ + +let localServerUrl: string | null = null + +export function setLocalServerUrl(url: string): void { + localServerUrl = url +} + +/** + * Returns null when the server has not bound yet. Callers that + * depend on the URL should treat null as "not ready"; the boot path + * always sets it before any route handler executes. + */ +export function getLocalServerUrl(): string | null { + return localServerUrl +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/main.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/main.ts new file mode 100644 index 000000000..59714e030 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/main.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env bun +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Bun entry point for the agent-mcp-interface server. + * + * Binds Hono on 127.0.0.1 — same posture as @browseros/server. The + * loopback restriction is what lets us run with wildcard CORS and + * accept `null` Origin requests from the future WXT extension + * loading via chrome-extension://. No external network reachability. + * + * Routes are mounted under `/cockpit` so the URL shape matches what + * `createCockpitRoutes` produces when the cockpit is embedded inside + * `@browseros/server`'s runtime (which is the production path). The + * UI client and agent-mcp-manager harness configs use a single base + * URL shape (`http://127.0.0.1:/cockpit/...`) regardless of + * which runtime is hosting them, so a profile created against + * standalone keeps working when the user later switches to the + * merged runtime on the same port (and vice versa). + * + * The agent-mcp-ui extension reads PROD_API_PORT off the shared port + * constant; in dev it can pick up an `?apiUrl=` override published + * by whichever launcher started this process. + */ + +if (typeof Bun === 'undefined') { + // biome-ignore lint/suspicious/noConsole: pre-logger bootstrap notice + console.error( + 'agent-mcp-interface requires the Bun runtime. Install Bun (https://bun.sh) and re-run with `bun src/main.ts`.', + ) + process.exit(1) +} + +import { Hono } from 'hono' +import { env } from './env' +import { bootstrapBrowserosBrowser } from './lib/browser-bootstrap' +import { setBrowserSession } from './lib/browser-session' +import { logger } from './lib/logger' +import { migrateMcpUrls } from './lib/migrate-mcp-urls' +import { setLocalServerUrl } from './local-server-url' +import server from './server' +import { COCKPIT_MOUNT_PREFIX } from './shared/port' + +async function start(): Promise { + const root = new Hono().route(COCKPIT_MOUNT_PREFIX, server) + const httpServer = Bun.serve({ + hostname: '127.0.0.1', + port: env.port, + fetch: root.fetch, + }) + const url = `http://${httpServer.hostname}:${httpServer.port}${COCKPIT_MOUNT_PREFIX}` + setLocalServerUrl(url) + logger.info('agent-mcp-interface listening', { url }) + + // Attach to the BrowserOS Chromium so MCP `tools/call` dispatches + // hit a real browser. The bootstrap soft-fails when BrowserOS is + // not reachable: the cockpit keeps serving the UI, profile CRUD, + // harness installs, and `tools/list`, and `tools/call` continues + // to short-circuit with the existing "session not connected" + // wire shape until the user restarts the cockpit with BrowserOS + // up. Reattach on transient drops is the CdpBackend's job (we + // pass `exitOnReconnectFailure: false` so it does not kill the + // process). + const bootstrap = await bootstrapBrowserosBrowser() + if (bootstrap) { + setBrowserSession(bootstrap.session) + logger.info('cockpit attached to browseros browser', { + cdpPort: env.cdpPort, + }) + // `exiting` guards against double-cleanup when a supervisor sends + // SIGINT and SIGTERM back-to-back. `process.once` removes each + // handler independently, so without the flag a SIGTERM that + // arrives while the SIGINT cleanup is still in flight would + // restart `disconnect()` on an already-closing CDP connection. + // The kill switch guarantees forward progress: a hung + // `cdp.disconnect()` (half-open socket, network stall) would + // otherwise leave the process stuck because both handlers have + // already been removed and only SIGKILL could recover it. + let exiting = false + const cleanup = (): void => { + if (exiting) return + exiting = true + setTimeout(() => process.exit(1), 5_000).unref() + bootstrap.disconnect().finally(() => process.exit(0)) + } + process.once('SIGINT', cleanup) + process.once('SIGTERM', cleanup) + } + + // Mirror what createCockpitRoutes does in the merged runtime: sweep + // every stored profile and rewrite its harness install + mcpUrl to + // the new `/cockpit`-prefixed shape if it carried the pre-merge + // URL. Idempotent — a second run is a no-op once every profile is + // up to date. The factory in the production path runs the same + // sweep at boot. + const buildMcpUrlForMigration = (slug: string): string => `${url}/mcp/${slug}` + void migrateMcpUrls(buildMcpUrlForMigration) + .then((result) => + logger.info('mcpUrl migration finished', { + migrated: result.migrated, + skipped: result.skipped, + failed: result.failed, + }), + ) + .catch((err: unknown) => + logger.error('mcpUrl migration failed unexpectedly', { + error: err instanceof Error ? err.message : String(err), + }), + ) +} + +void start() diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/manager.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/manager.ts new file mode 100644 index 000000000..2fd870a51 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/manager.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Per-slug MCP server orchestration. Resolves an incoming `/mcp/:slug` + * request against the agents directory, builds a fresh `McpServer` + * with the catalog of tools registered for that agent, and lets the + * SDK's Web Standard transport handle the actual HTTP framing. + * + * Why per-request servers instead of cached singletons: + * The SDK's `Protocol.connect(transport)` rejects when the server + * is already connected to a different transport, and stateless + * Streamable HTTP transports are single-use by design. The cleanest + * way to support concurrent requests for the same slug is to build + * a fresh server + transport per request. The cost is a handful of + * `registerTool` calls (each just sets a Map entry), which is + * negligible. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { logger } from '../lib/logger' +import { findBySlug } from '../routes/agents/service' +import { registerBrowserTools } from './register' + +const SERVER_NAME = 'browseros-agent-mcp-interface' +const SERVER_VERSION = '0.0.1' + +/** + * Handles a single `/mcp/:slug` request end-to-end. Returns either: + * - 404 Response if the slug doesn't match any agent profile, OR + * - the SDK's Response from the Streamable HTTP transport. + */ +export async function handleMcpRequest( + slug: string, + request: Request, +): Promise { + const agent = await findBySlug(slug) + if (!agent) { + return new Response(JSON.stringify({ error: 'agent not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }) + } + + const server = new McpServer({ + name: SERVER_NAME, + title: `BrowserOS / ${agent.name}`, + version: SERVER_VERSION, + }) + + registerBrowserTools(server, agent) + + // Stateless mode: each request gets its own short-lived transport, + // we return its Response directly. JSON response is enabled so the + // simplest clients (curl, MCP inspector) work without negotiating + // SSE framing. + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }) + + try { + await server.connect(transport) + return await transport.handleRequest(request) + } catch (err) { + logger.error('mcp request failed', { + slug, + error: err instanceof Error ? err.message : String(err), + }) + return new Response(JSON.stringify({ error: 'internal mcp error' }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }) + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/register-fn.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/register-fn.ts new file mode 100644 index 000000000..d373565e6 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/register-fn.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * The SDK's `McpServer.registerTool` overload set is parameterised on + * the SDK's internal zod-shape type, which mismatches with the zod v4 + * shape this package uses. Retyping `registerTool` to a concrete, + * non-generic signature avoids a TS "excessively deep instantiation" + * error while keeping the call shape honest. + * + * Same workaround `apps/server/src/tools/browser/register.ts` uses. + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { ZodRawShape } from 'zod' + +export type ToolResultContent = { + type: 'text' + text: string +} + +export interface ToolResult { + content: ToolResultContent[] + isError?: boolean + structuredContent?: unknown +} + +export type ToolHandler = ( + args: Record, + extra?: { signal?: AbortSignal }, +) => Promise + +export type RegisterFn = ( + name: string, + config: { + description: string + inputSchema?: ZodRawShape + annotations?: Record + }, + handler: ToolHandler, +) => void + +export function asRegister(server: McpServer): RegisterFn { + return server.registerTool.bind(server) as unknown as RegisterFn +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/register.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/register.ts new file mode 100644 index 000000000..adc4c91ea --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/mcp/register.ts @@ -0,0 +1,260 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Wires every browser tool from `@browseros/server`'s catalogue onto + * a per-agent MCP server with a permission gate in front. Each + * dispatch: + * + * 1. Maps the tool name to a permission verb in the cockpit's + * catalog space. + * 2. Looks up a domain hint from the agent (real per-page URL + * tracking is a future-phase concern; today we use the agent's + * first declared site). + * 3. Calls `permissions.check(agent, verb, domain)` and + * short-circuits on `block` / `ask`. + * 4. Looks up the live BrowserSession; if not yet wired, returns + * a structured "session not connected" error so the wire shape + * stays honest. + * 5. Hands off to `executeTool` from `@browseros/server`'s tool + * framework. That handles arg validation, error formatting, + * tab-id metadata, and result composition. + * + * Known coarseness: the real catalogue's `act` tool covers every + * mutation (click/type/fill/press/hover/scroll). We map it onto the + * cockpit's `input` verb today, which means a site rule keyed on + * `payments` does NOT clamp an `act({kind:'click'})` on a payment + * button. Finer-grained classification (per-arg verb extraction) is + * a follow-up. + */ + +import { executeTool } from '@browseros/server/tools/browser/framework' +import { BROWSER_TOOLS } from '@browseros/server/tools/browser/registry' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { ZodRawShape } from 'zod' +import { getBrowserSession } from '../lib/browser-session' +import { logger } from '../lib/logger' +import { extractPageId, tabActivityRegistry } from '../lib/tab-activity' +import type { StoredAgentProfile } from '../routes/agents/schemas' +import { check } from '../services/permissions' +import { asRegister, type ToolResult } from './register-fn' + +/** + * Schemes the cockpit refuses to forward to `navigate`, regardless of + * what the parent server's tool schema would accept. The real navigate + * tool's zod input is `z.string().optional()` with no scheme check, so + * without this guard a `javascript:`, `file:`, or `data:` URL would + * pass the permission gate and reach the CDP layer. Re-asserts the + * defense the old per-tool wrapper had before we switched to the real + * catalogue. + */ +const NAVIGATE_BLOCKED_SCHEMES = new Set(['javascript:', 'file:', 'data:']) + +/** + * Maps each tool in the real catalogue to a permission verb. Tools + * that mutate site context (`tabs`, `navigate`, `windows`, + * `tab_groups`) map to `navigate`; `upload` maps to the catalog's + * own `upload` verb; everything else maps to `input`, the cockpit's + * catch-all for "click / type / read / etc.". + * + * `act`, `run`, `evaluate`, and `download` are deliberately lumped + * under `input` despite being the higher-risk tools in the surface: + * `download` has no dedicated catalog verb yet, and `run` / + * `evaluate` execute arbitrary JS in page context. A richer + * classifier (look at the `kind` arg of `act`, block `run` / + * `evaluate` unless the agent opts in, add a `download` verb) is + * the follow-up that closes this gap. Dispatches of the + * arbitrary-script tools are logged for audit until that lands. + */ +const TOOL_TO_VERB: Record = { + tabs: 'navigate', + navigate: 'navigate', + windows: 'navigate', + tab_groups: 'navigate', + upload: 'upload', + snapshot: 'input', + diff: 'input', + act: 'input', + read: 'input', + grep: 'input', + screenshot: 'input', + wait: 'input', + pdf: 'input', + download: 'input', + run: 'input', + evaluate: 'input', +} + +const ARBITRARY_SCRIPT_TOOLS = new Set(['run', 'evaluate']) + +/** + * Picks a domain for the permission check. `navigate` carries the + * target URL in its args, which is the cleanest signal we have until + * per-page URL tracking ships. Every other tool falls back to the + * agent's first declared site, or `'*'` so wildcard site rules still + * fire for agents with an empty `selectedSites`. + */ +function domainForCall( + toolName: string, + rawArgs: unknown, + agent: StoredAgentProfile, +): string { + if ( + toolName === 'navigate' && + typeof rawArgs === 'object' && + rawArgs !== null + ) { + const url = (rawArgs as { url?: unknown }).url + if (typeof url === 'string' && url.length > 0) { + try { + const hostname = new URL(url).hostname + if (hostname) return hostname + } catch { + // fall through to the agent hint + } + } + } + return agent.selectedSites[0] ?? '*' +} + +/** + * Records a successful dispatch into the tab-activity registry. The + * homepage attributes the tab to the agent and surfaces the latest + * tool name. Failed dispatches and tools without a `page` arg are + * skipped at the call site by `extractPageId` returning `null`. + */ +function recordSuccessfulDispatch(args: { + toolName: string + rawArgs: unknown + agent: StoredAgentProfile + session: ReturnType +}): void { + if (!args.session) return + const pageId = extractPageId(args.toolName, args.rawArgs) + if (pageId === null) return + const live = args.session.pages.getInfo(pageId) + if (!live) return + tabActivityRegistry.recordTool({ + agentId: args.agent.id, + slug: args.agent.slug, + pageId, + targetId: live.targetId, + toolName: args.toolName, + }) +} + +export function registerBrowserTools( + server: McpServer, + agent: StoredAgentProfile, +): void { + const register = asRegister(server) + for (const tool of BROWSER_TOOLS) { + const verb = TOOL_TO_VERB[tool.name] ?? 'input' + register( + tool.name, + { + description: tool.description, + // The tool's zod shape is v3 (apps/server's pin); our SDK + // wrapper is typed against v4. Runtime is compatible — both + // produce equivalent JSON Schema for the shapes in use here. + // Cast at the boundary keeps the mismatch isolated. + inputSchema: tool.input.shape as unknown as ZodRawShape, + ...(tool.annotations && { + annotations: tool.annotations as Record, + }), + }, + async (rawArgs, extra) => { + if (tool.name === 'navigate') { + const url = (rawArgs as { url?: unknown } | null | undefined)?.url + if (typeof url === 'string' && url.length > 0) { + const scheme = url.slice(0, url.indexOf(':') + 1).toLowerCase() + if (NAVIGATE_BLOCKED_SCHEMES.has(scheme)) { + return { + content: [ + { + type: 'text', + text: `navigate refuses ${scheme} URLs; only http(s) is allowed`, + }, + ], + isError: true, + } satisfies ToolResult + } + } + } + const domain = domainForCall(tool.name, rawArgs, agent) + const verdict = await check({ + agentId: agent.id, + verb, + domain, + }) + if (verdict.verdict === 'block') { + return { + content: [ + { + type: 'text', + text: `blocked by ${verdict.source}: ${tool.name} on ${domain}`, + }, + ], + isError: true, + } satisfies ToolResult + } + if (verdict.verdict === 'ask') { + return { + content: [ + { + type: 'text', + text: `approval required for ${tool.name} on ${domain}; the cockpit will surface this once run-lifecycle approvals ship`, + }, + ], + isError: true, + } satisfies ToolResult + } + + const session = getBrowserSession() + if (!session) { + return { + content: [ + { + type: 'text', + text: 'browser session not connected; the cockpit runtime has not been wired to a live Chromium yet', + }, + ], + isError: true, + } satisfies ToolResult + } + + if (ARBITRARY_SCRIPT_TOOLS.has(tool.name)) { + // `run` and `evaluate` execute arbitrary JS in the page's + // context. They map to the same `input` verb as low-risk + // reads today, so an agent with `input: 'Auto'` runs + // scripts without confirmation. A dedicated catalog verb + // (and a UI surface for it) is the proper fix; this log + // keeps the dispatch auditable until that lands. + logger.warn('cockpit dispatched arbitrary-script tool', { + tool: tool.name, + agentId: agent.id, + domain, + }) + } + const result = await executeTool(tool, rawArgs, { + session, + signal: extra?.signal, + }) + if (!result.isError) { + recordSuccessfulDispatch({ + toolName: tool.name, + rawArgs, + agent, + session, + }) + } + return { + content: result.content as ToolResult['content'], + isError: result.isError, + structuredContent: result.structuredContent, + } + }, + ) + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/routes/agents/index.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/agents/index.ts new file mode 100644 index 000000000..af0843602 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/agents/index.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * /agents route chain. Thin Hono layer over `./service`: translate + * HTTP shape in and out, surface 404 when the service returns null, + * and let `zValidator` reject malformed bodies with structured 400s. + * + * The chained `.post / .get / .patch / .delete` calls preserve the + * inferred shape `AppType` needs; do not break the chain. + */ + +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { HttpError } from '../../lib/errors' +import { newAgentValuesSchema } from './schemas' +import { + create, + getDetail, + list, + regenerateMcpUrl, + remove, + update, +} from './service' + +export const agentsRoute = new Hono() + .post('/agents', zValidator('json', newAgentValuesSchema), async (c) => { + const body = c.req.valid('json') + const created = await create(body) + return c.json(created, 201) + }) + .get('/agents', async (c) => c.json(await list())) + .get('/agents/:id', async (c) => { + const detail = await getDetail(c.req.param('id')) + if (!detail) throw new HttpError(404, 'agent not found') + return c.json(detail) + }) + .patch('/agents/:id', zValidator('json', newAgentValuesSchema), async (c) => { + const id = c.req.param('id') + const body = c.req.valid('json') + const updated = await update(id, body) + if (!updated) throw new HttpError(404, 'agent not found') + return c.json(updated) + }) + .delete('/agents/:id', async (c) => { + const removed = await remove(c.req.param('id')) + if (!removed) throw new HttpError(404, 'agent not found') + return c.json(removed) + }) + .post('/agents/:id/mcp-url:regenerate', async (c) => { + const rotated = await regenerateMcpUrl(c.req.param('id')) + if (!rotated) throw new HttpError(404, 'agent not found') + return c.json(rotated) + }) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/routes/agents/schemas.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/agents/schemas.ts new file mode 100644 index 000000000..4a5e8a43f --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/agents/schemas.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Zod shapes for the /agents routes. The UI's wizard-side validation + * lives in `apps/agent-mcp-ui/screens/new-agent/new-agent.schemas.ts` + * (it needs zod for client-side form errors); these schemas are the + * wire contract the typed client picks up via AppType. + * + * Storage shape extends the wire shape with server-managed fields + * (id, slug, mcpUrl, status, timestamps). The directory's projection + * shape lives at the bottom and is derived from the storage shape. + */ + +import { z } from 'zod' + +/** + * The first 7 entries align 1:1 with `agent-mcp-manager`'s AgentId + * space. The last 2 are BrowserOS-internal harnesses with no + * third-party config to write — they short-circuit as a no-op + * inside `services/harness-install`. Keep these in sync with + * apps/agent-mcp-ui/screens/new-agent/new-agent.schemas.ts. + */ +export const harnessEnum = z.enum([ + 'Claude Code', + 'Claude Desktop', + 'Cursor', + 'VS Code', + 'Zed', + 'Codex', + 'Gemini CLI', + 'Hermes', + 'OpenClaw', +]) +export type Harness = z.infer + +export const loginModeEnum = z.enum(['profile', 'all', 'selective']) +export type LoginMode = z.infer + +export const approvalVerdictEnum = z.enum(['Auto', 'Ask', 'Block']) +export type ApprovalVerdict = z.infer + +export const profileStatusEnum = z.enum(['configured', 'paused', 'disabled']) +export type ProfileStatus = z.infer + +export const customAclRuleSchema = z.object({ + id: z.string(), + label: z.string().min(1), + domain: z.string().min(1), +}) +export type CustomAclRule = z.infer + +/** Wire shape: POST / PATCH body, also GET /:id response. Mirrors UI's NewAgentValues. */ +export const newAgentValuesSchema = z.object({ + name: z.string().trim().min(1), + harness: harnessEnum, + loginMode: loginModeEnum, + selectedSites: z.array(z.string()), + approvals: z.record(z.string(), approvalVerdictEnum), + aclRuleIds: z.array(z.string()), + customAclRules: z.array(customAclRuleSchema), +}) +export type NewAgentValues = z.infer + +/** On-disk shape under /mcp-interface/agents/.json. */ +export const storedAgentProfileSchema = newAgentValuesSchema.extend({ + id: z.string(), + slug: z.string(), + mcpUrl: z.string(), + status: profileStatusEnum, + createdAt: z.string(), + updatedAt: z.string(), +}) +export type StoredAgentProfile = z.infer + +/** Wire shape: GET / response. Directory row. */ +export const agentProfileSummarySchema = z.object({ + id: z.string(), + name: z.string(), + harness: harnessEnum, + loginScopeLabel: z.string(), + loginCount: z.number(), + aclRuleCount: z.number(), + blockedActionCount: z.number(), + alwaysAllowCount: z.number(), + lastRunAt: z.string(), + status: profileStatusEnum, + mcpUrl: z.string(), +}) +export type AgentProfileSummary = z.infer + +/** + * Outcome of the harness MCP install side-effect that runs alongside + * create. The wizard surfaces `installed` and `message` directly; the + * `configPath` is filled when a real file was written so the cockpit + * can hint at where the entry landed. + */ +export const harnessInstallOutcomeSchema = z.object({ + installed: z.boolean(), + message: z.string(), + configPath: z.string().optional(), +}) +export type HarnessInstallOutcome = z.infer + +/** Wire shape: POST / response. Carries the rail's display strings. */ +export const createdAgentSchema = z.object({ + id: z.string(), + name: z.string(), + harness: harnessEnum, + slug: z.string(), + mcpUrl: z.string(), + cliCommand: z.string(), + harnessInstall: harnessInstallOutcomeSchema, +}) +export type CreatedAgent = z.infer + +/** Wire shape: PATCH / response. */ +export const updatedAgentSchema = storedAgentProfileSchema +export type UpdatedAgent = z.infer + +/** Wire shape: DELETE / and regenerate. */ +export const idAckSchema = z.object({ id: z.string() }) +export type IdAck = z.infer + +/** Wire shape: DELETE / response — carries the uninstall side-effect. */ +export const deletedAgentSchema = z.object({ + id: z.string(), + harnessUninstall: harnessInstallOutcomeSchema, +}) +export type DeletedAgent = z.infer + +export const regeneratedMcpUrlSchema = z.object({ + id: z.string(), + mcpUrl: z.string(), +}) +export type RegeneratedMcpUrl = z.infer diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/routes/agents/service.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/agents/service.ts new file mode 100644 index 000000000..c05e75204 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/agents/service.ts @@ -0,0 +1,345 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * File-backed agent profile service. One profile per file at + * /mcp-interface/agents/.json keyed by a nanoid; + * the slug is the user-facing identifier and is unique across all + * profiles. mcpUrl is recomputed from getLocalServerUrl() on every + * read so a port change between boots doesn't strand the stored + * value. + * + * Route handlers stay thin: they translate HTTP shape and surface + * 404s; everything else (validation, persistence, slug resolution, + * derivation) happens here. + */ + +import { nanoid } from 'nanoid' +import { env } from '../../env' +import { AsyncMutex } from '../../lib/async-mutex' +import { logger } from '../../lib/logger' +import { toSlug, uniqueSlug } from '../../lib/slug' +import { listFiles, readJson, removeFile, writeJson } from '../../lib/storage' +import { getLocalServerUrl } from '../../local-server-url' +import { + installForAgent, + reconcileHarnessLink, + uninstallForAgent, +} from '../../services/harness-install' +import { + type AgentProfileSummary, + type CreatedAgent, + type DeletedAgent, + type NewAgentValues, + type RegeneratedMcpUrl, + type StoredAgentProfile, + storedAgentProfileSchema, +} from './schemas' + +const AGENTS_SUBDIR = 'agents' +const TOTAL_PROFILE_LOGINS = 47 + +/** + * Serialises every slug-mutating operation (create / update / + * regenerateMcpUrl) so the read-snapshot → uniqueSlug → write + * window cannot race against itself. Reads (list / getDetail) stay + * lock-free; concurrent writes to different ids that don't touch + * the slug space could in principle drop the mutex too, but the cost + * of the queue is negligible and keeping all three under the same + * lock means the slug-uniqueness invariant holds without per-op + * reasoning. + */ +const slugMutex = new AsyncMutex() + +/** + * `id` is always a server-generated nanoid(8). Validate it before + * forwarding to the filesystem so a traversal-shaped value (e.g. + * URL-encoded `..%2Fconfig`) can never reach the storage layer even + * if a future route forwards user input directly. Nanoid's default + * alphabet is `A-Za-z0-9_-`; we cap the length to keep the file name + * predictable. + */ +const ID_PATTERN = /^[A-Za-z0-9_-]+$/ +const MAX_ID_LENGTH = 64 + +export function isValidId(id: string): boolean { + return id.length > 0 && id.length <= MAX_ID_LENGTH && ID_PATTERN.test(id) +} + +function fileFor(id: string): string { + return `${AGENTS_SUBDIR}/${id}.json` +} + +function nowIso(): string { + return new Date().toISOString() +} + +function baseUrl(): string { + return getLocalServerUrl() ?? `http://127.0.0.1:${env.port}` +} + +function buildMcpUrl(slug: string): string { + return `${baseUrl()}/mcp/${slug}` +} + +function buildCliCommand(slug: string): string { + return `mcp add ${slug}` +} + +/** + * All readable stored profiles, in arbitrary order. A single corrupt + * file is logged + skipped rather than rejecting the whole list, so + * one bad agent json (manual edit, partial migration, half-written + * file on a weird FS) can't brick the whole package: the directory + * still loads and `create` can still write new profiles. + */ +async function loadAll(): Promise { + const names = await listFiles(AGENTS_SUBDIR) + const settled = await Promise.allSettled( + names.map((name) => + readJson(`${AGENTS_SUBDIR}/${name}`, storedAgentProfileSchema), + ), + ) + const profiles: StoredAgentProfile[] = [] + for (let i = 0; i < settled.length; i++) { + const result = settled[i] + if (result.status === 'fulfilled') { + profiles.push(result.value) + } else { + logger.warn('skipping unreadable agent profile', { + file: `${AGENTS_SUBDIR}/${names[i]}`, + error: + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + }) + } + } + return profiles +} + +/** + * Stored profile for a slug, or null when no agent owns that slug. + * Used by the MCP route to resolve `/mcp/:slug` against the agents + * directory. Slugs are unique per Phase 1's `uniqueSlug` invariant so + * the linear scan never collides. + */ +export async function findBySlug( + slug: string, +): Promise { + const all = await loadAll() + return all.find((profile) => profile.slug === slug) ?? null +} + +/** Stored profile for an id, or null when the file is missing. */ +async function loadById(id: string): Promise { + if (!isValidId(id)) return null + try { + return await readJson(fileFor(id), storedAgentProfileSchema) + } catch (err) { + if ( + err instanceof Error && + (err.name === 'StorageNotFoundError' || + err.name === 'StorageInvalidPathError') + ) { + return null + } + throw err + } +} + +function summariseProfile(profile: StoredAgentProfile): AgentProfileSummary { + const blockedActionCount = Object.values(profile.approvals).filter( + (verdict) => verdict === 'Block', + ).length + const loginCount = + profile.loginMode === 'selective' + ? profile.selectedSites.length + : TOTAL_PROFILE_LOGINS + const loginScopeLabel = + profile.loginMode === 'selective' + ? `Selective (${profile.selectedSites.length})` + : profile.loginMode === 'all' + ? `All my logins (${TOTAL_PROFILE_LOGINS})` + : `Current profile (${TOTAL_PROFILE_LOGINS})` + return { + id: profile.id, + name: profile.name, + harness: profile.harness, + loginScopeLabel, + loginCount, + aclRuleCount: profile.aclRuleIds.length, + blockedActionCount, + alwaysAllowCount: 0, + lastRunAt: 'Never run', + status: profile.status, + mcpUrl: buildMcpUrl(profile.slug), + } +} + +function stripWizardShape(profile: StoredAgentProfile): NewAgentValues { + return { + name: profile.name, + harness: profile.harness, + loginMode: profile.loginMode, + selectedSites: [...profile.selectedSites], + approvals: { ...profile.approvals }, + aclRuleIds: [...profile.aclRuleIds], + customAclRules: profile.customAclRules.map((rule) => ({ ...rule })), + } +} + +export async function list(): Promise { + const profiles = await loadAll() + return profiles + .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)) + .map((profile) => summariseProfile(profile)) +} + +export async function getDetail(id: string): Promise { + const profile = await loadById(id) + return profile ? stripWizardShape(profile) : null +} + +export async function create(input: NewAgentValues): Promise { + return slugMutex.run(async () => { + const id = nanoid(8) + const existing = await loadAll() + const slug = uniqueSlug( + toSlug(input.name), + new Set(existing.map((p) => p.slug)), + ) + const now = nowIso() + const profile: StoredAgentProfile = { + ...input, + id, + slug, + mcpUrl: buildMcpUrl(slug), + status: 'configured', + createdAt: now, + updatedAt: now, + } + await writeJson(fileFor(id), profile, storedAgentProfileSchema) + // Best-effort harness install. A failure here does NOT roll back + // the profile; the user can retry or fix the harness state and + // we'll attempt again on the next create. The outcome rides + // back in the response so the wizard can surface it. + const harnessInstall = await installForAgent({ + slug: profile.slug, + mcpUrl: profile.mcpUrl, + harness: profile.harness, + }) + return { + id, + name: profile.name, + harness: profile.harness, + slug, + mcpUrl: profile.mcpUrl, + cliCommand: buildCliCommand(slug), + harnessInstall, + } + }) +} + +export async function update( + id: string, + input: NewAgentValues, +): Promise { + return slugMutex.run(async () => { + const existing = await loadById(id) + if (!existing) return null + const existingProfiles = await loadAll() + const nameSlug = toSlug(input.name) + const slug = + nameSlug === toSlug(existing.name) + ? existing.slug + : uniqueSlug( + nameSlug, + new Set( + existingProfiles + .filter((profile) => profile.id !== id) + .map((profile) => profile.slug), + ), + ) + const next: StoredAgentProfile = { + ...existing, + ...input, + id, + slug, + mcpUrl: buildMcpUrl(slug), + status: existing.status, + createdAt: existing.createdAt, + updatedAt: nowIso(), + } + await writeJson(fileFor(id), next, storedAgentProfileSchema) + // Best-effort harness reconcile: if the harness or slug rotated, + // wire the new entry into the new harness and drop the old one. + // Failures are logged inside the helpers and do NOT roll back the + // profile rewrite. + await reconcileHarnessLink({ + before: { slug: existing.slug, harness: existing.harness }, + after: { slug: next.slug, mcpUrl: next.mcpUrl, harness: next.harness }, + }) + return next + }) +} + +export async function remove(id: string): Promise { + if (!isValidId(id)) return null + // Load the profile before we wipe it so we can issue the uninstall + // with the right harness + slug. A delete that races a parallel + // delete may find no profile here; that's the "already gone" path + // and we 404. + const profile = await loadById(id) + if (!profile) return null + // Remove the file FIRST. Two concurrent deletes both observe the + // same profile via loadById, but only the winner's removeFile + // returns true; the loser exits with 404 here and never + // side-effects the harness. Without this order, both calls would + // run uninstallForAgent and the loser would still report 404. + const existed = await removeFile(fileFor(id)) + if (!existed) return null + const harnessUninstall = await uninstallForAgent({ + slug: profile.slug, + harness: profile.harness, + }) + return { id, harnessUninstall } +} + +export async function regenerateMcpUrl( + id: string, +): Promise { + return slugMutex.run(async () => { + const existing = await loadById(id) + if (!existing) return null + const profiles = await loadAll() + const taken = new Set( + profiles + .filter((profile) => profile.id !== id) + .map((profile) => profile.slug), + ) + // Route the whole base through toSlug so the nanoid suffix can't + // smuggle `_` or `-` characters into the slug; the rotated slug + // stays in the canonical lowercase-alphanum-with-single-hyphens + // shape. + const base = toSlug(`${existing.name} ${nanoid(6)}`) + const slug = uniqueSlug(base, taken) + const next: StoredAgentProfile = { + ...existing, + slug, + mcpUrl: buildMcpUrl(slug), + updatedAt: nowIso(), + } + await writeJson(fileFor(id), next, storedAgentProfileSchema) + // The whole point of rotating is to issue a fresh URL to the + // harness; reconcile the link so the harness picks it up + // automatically. Harness is unchanged so only the slug pair + // differs. + await reconcileHarnessLink({ + before: { slug: existing.slug, harness: existing.harness }, + after: { slug: next.slug, mcpUrl: next.mcpUrl, harness: next.harness }, + }) + return { id, mcpUrl: next.mcpUrl } + }) +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/routes/mcp/index.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/mcp/index.ts new file mode 100644 index 000000000..558c65d14 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/mcp/index.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * `/mcp/:slug` is the HTTP entry point a harness (Claude Desktop, + * Cursor, Codex, Zed) hits to talk to the cockpit. The slug is the + * route segment the wizard generated for an agent; everything past + * the route layer is delegated to the per-slug MCP manager which + * owns the SDK plumbing. + * + * `app.all` matches every method the Streamable HTTP transport + * understands (POST for JSON-RPC, GET for SSE, DELETE for session + * termination). The transport handles method dispatch internally. + */ + +import { Hono } from 'hono' +import { handleMcpRequest } from '../../mcp/manager' + +export const mcpRoute = new Hono().all('/mcp/:slug', async (c) => { + return handleMcpRequest(c.req.param('slug'), c.req.raw) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/routes/permissions/index.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/permissions/index.ts new file mode 100644 index 000000000..afd1a302e --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/permissions/index.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * /permissions route chain. Returns the system-wide approval catalog + * baked into `lib/approval-catalog.ts`. Customisation (PUT/PATCH on + * the catalog) is intentionally deferred; the UI keeps its own copy as + * a fetch-failure fallback. + */ + +import { Hono } from 'hono' +import { APPROVAL_CATEGORIES } from '../../lib/approval-catalog' + +export const permissionsRoute = new Hono().get('/permissions/catalog', (c) => + c.json(APPROVAL_CATEGORIES), +) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/routes/site-rules/index.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/site-rules/index.ts new file mode 100644 index 000000000..629080b5f --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/site-rules/index.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * /site-rules route chain. Thin Hono layer over `./service`: zValidator + * rejects malformed bodies with structured 400s, and DELETE surfaces + * the service's null-return as 404. The chained `.get / .post / + * .delete` calls preserve the inferred shape `AppType` needs; do not + * break the chain. + */ + +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { HttpError } from '../../lib/errors' +import { addSiteRuleSchema } from './schemas' +import { add, list, remove } from './service' + +export const siteRulesRoute = new Hono() + .get('/site-rules', async (c) => c.json(await list())) + .post('/site-rules', zValidator('json', addSiteRuleSchema), async (c) => { + const body = c.req.valid('json') + const created = await add(body) + return c.json(created, 201) + }) + .delete('/site-rules/:id', async (c) => { + const removed = await remove(c.req.param('id')) + if (!removed) throw new HttpError(404, 'site rule not found') + return c.json(removed) + }) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/routes/site-rules/schemas.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/site-rules/schemas.ts new file mode 100644 index 000000000..0ededbcb9 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/site-rules/schemas.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Zod shapes for the /site-rules routes. Wire shape mirrors + * apps/agent-mcp-ui/modules/api/site-rules.hooks.ts so the UI's + * existing `SiteRule` / `AddSiteRuleVariables` consumers stay byte + * identical after the hook swap. + * + * Storage shape is a single file holding an array; site-rule lookups + * are always full-table scans (typical user has under 20 rules) and + * Phase 5's `permissions.check` reads the whole set on every dispatch. + */ + +import { z } from 'zod' + +export const siteRuleActionEnum = z.enum([ + 'payments', + 'submit', + 'delete', + 'navigate', + 'upload', + 'admin', +]) +export type SiteRuleAction = z.infer + +/** Wire shape: POST body. */ +export const addSiteRuleSchema = z.object({ + label: z.string().trim().min(1), + domain: z.string().trim().min(1), + action: siteRuleActionEnum, +}) +export type AddSiteRuleVariables = z.infer + +/** Wire shape: GET / and POST / response item. Also the on-disk row shape. */ +export const siteRuleSchema = z.object({ + id: z.string(), + label: z.string().min(1), + domain: z.string().min(1), + action: siteRuleActionEnum, +}) +export type SiteRule = z.infer + +/** Storage wrapper: site-rules.json holds an array. */ +export const siteRulesFileSchema = z.array(siteRuleSchema) +export type SiteRulesFile = z.infer + +/** Wire shape: DELETE response. */ +export const idAckSchema = z.object({ id: z.string() }) +export type IdAck = z.infer diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/routes/site-rules/service.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/site-rules/service.ts new file mode 100644 index 000000000..4adf4fb3e --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/site-rules/service.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * File-backed site-rules service. All rules live in a single array + * file at /mcp-interface/site-rules.json (typical user + * count is under 20, full-array scans on every read are fine and the + * single-file shape lets the UI's list view round-trip in one I/O + * call). + * + * Mutations go through a per-service AsyncMutex so two concurrent + * `add` calls cannot drop one (the read-then-rewrite window is the + * same shape the agents service guards in Phase 1). Reads are + * lock-free. + */ + +import { nanoid } from 'nanoid' +import { AsyncMutex } from '../../lib/async-mutex' +import { matchDomain } from '../../lib/match-domain' +import { + fileExists, + readJson, + StorageNotFoundError, + writeJson, +} from '../../lib/storage' +import { + type AddSiteRuleVariables, + type SiteRule, + siteRulesFileSchema, +} from './schemas' + +const FILE = 'site-rules.json' +const ID_PATTERN = /^[A-Za-z0-9_-]+$/ +const MAX_ID_LENGTH = 64 + +/** + * `id` is server-generated nanoid; we validate it here too so a + * traversal-shaped value handed to the DELETE handler resolves to + * not-found before the storage layer ever sees it. Same defence-in- + * depth pattern as the agents service. + */ +function isValidId(id: string): boolean { + return id.length > 0 && id.length <= MAX_ID_LENGTH && ID_PATTERN.test(id) +} + +const mutex = new AsyncMutex() + +async function loadAll(): Promise { + try { + return await readJson(FILE, siteRulesFileSchema) + } catch (err) { + if (err instanceof StorageNotFoundError) return [] + throw err + } +} + +export async function list(): Promise { + return loadAll() +} + +export async function add(input: AddSiteRuleVariables): Promise { + return mutex.run(async () => { + const existing = await loadAll() + const rule: SiteRule = { + id: nanoid(8), + label: input.label, + domain: input.domain, + action: input.action, + } + const next = [...existing, rule] + await writeJson(FILE, next, siteRulesFileSchema) + return rule + }) +} + +export async function remove(id: string): Promise<{ id: string } | null> { + if (!isValidId(id)) return null + return mutex.run(async () => { + if (!(await fileExists(FILE))) return null + const existing = await loadAll() + const next = existing.filter((rule) => rule.id !== id) + if (next.length === existing.length) return null + await writeJson(FILE, next, siteRulesFileSchema) + return { id } + }) +} + +/** + * In-process helper used by `permissions.check`. Returns every rule + * whose `(domain, action)` pair matches the request. Caller decides + * how to combine multiple matches (Phase 5: any match with a + * configured verb yields a clamp). + */ +export async function findMatching( + domain: string, + action: SiteRule['action'], +): Promise { + const rules = await loadAll() + return rules.filter( + (rule) => rule.action === action && matchDomain(rule.domain, domain), + ) +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/routes/system.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/system.ts new file mode 100644 index 000000000..4d822b592 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/system.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Hono } from 'hono' +// Package version is read off the bundled package.json. resolveJsonModule +// + bun's loader resolve this without a build step. +import pkg from '../../package.json' with { type: 'json' } +import { getLocalServerUrl } from '../local-server-url' + +export const systemRoute = new Hono() + .get('/system/health', (c) => c.json({ status: 'ok' as const })) + .get('/system/version', (c) => + c.json({ name: pkg.name, version: pkg.version }), + ) + .get('/system/url', (c) => c.json({ url: getLocalServerUrl() })) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/routes/tabs/index.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/tabs/index.ts new file mode 100644 index 000000000..8f3fb439c --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/routes/tabs/index.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Read endpoint backing the cockpit homepage's "which tabs are + * being driven right now" view. The registry behind this route is + * fed by `apps/agent-mcp-interface/src/mcp/register.ts` every time a + * browser tool dispatch succeeds; this route just publishes the + * current snapshot. Polling is the v1 transport (the UI hook polls + * every 1500 ms); SSE on `?stream=1` is a future option if polling + * proves chatty. + */ + +import { Hono } from 'hono' +import { tabActivityRegistry } from '../../lib/tab-activity' + +export const tabsRoute = new Hono().get('/tabs/activity', (c) => + c.json({ tabs: tabActivityRegistry.snapshot() }), +) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/server.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/server.ts new file mode 100644 index 000000000..246330461 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/server.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Hono application composition. The chained `.route('/', xxxRoute)` + * calls give us a `routes` reference whose inferred type captures + * every endpoint's input / output shape; we re-export that as + * `AppType` so the future agent-mcp-ui can build a fully typed + * hono-rpc client with `hc(baseUrl)`. + * + * Bun + loopback-only bind; the chain shape is the standard hono-rpc + * recipe. + */ + +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { HttpError } from './lib/errors' +import { logger } from './lib/logger' +import { agentsRoute } from './routes/agents' +import { mcpRoute } from './routes/mcp' +import { permissionsRoute } from './routes/permissions' +import { siteRulesRoute } from './routes/site-rules' +import { systemRoute } from './routes/system' +import { tabsRoute } from './routes/tabs' + +// Telemetry capture is injectable so the server module stays usable +// from the bun-test runner without pulling Sentry into the import +// graph. main.ts can wire a real capture; tests get the no-op. +export type RouteErrorHandler = ( + err: unknown, + path: string, + method: string, +) => void + +let captureRouteError: RouteErrorHandler = () => undefined + +export function setRouteErrorHandler(fn: RouteErrorHandler): void { + captureRouteError = fn +} + +const app = new Hono() + +// Loopback-only bind (see main.ts) makes wildcard CORS safe and +// dodges the `null` Origin a chrome-extension:// page sends when +// fetching from `http://127.0.0.1:`. +app.use('*', cors({ origin: '*' })) + +// Catch-all for genuinely unexpected errors. Routes today resolve +// their own expected failures (404s, validation) inline and return +// structured 4xx JSON. Anything that escapes that lands here, gets +// reported via the injected capture, and turns into a structured 5xx +// JSON body. +app.onError((err, c) => { + captureRouteError(err, c.req.path, c.req.method) + if (err instanceof HttpError) { + return c.json({ error: err.message }, err.status as 400 | 404 | 409 | 500) + } + const message = err instanceof Error ? err.message : 'internal error' + logger.error('Unhandled route error', { + path: c.req.path, + method: c.req.method, + error: message, + }) + return c.json({ error: message }, 500) +}) + +const routes = app + .route('/', systemRoute) + .route('/', agentsRoute) + .route('/', siteRulesRoute) + .route('/', permissionsRoute) + .route('/', mcpRoute) + .route('/', tabsRoute) + +export type AppType = typeof routes +export default routes diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/services/harness-install.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/services/harness-install.ts new file mode 100644 index 000000000..2106e4856 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/services/harness-install.ts @@ -0,0 +1,199 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Wires a cockpit agent profile into the user's chosen harness's MCP + * config file via `agent-mcp-manager`. Each cockpit profile becomes + * one entry in the harness's config, keyed by the profile's slug, + * pointing at `http://127.0.0.1:9200/mcp/`. + * + * `installForAgent` runs on POST /agents (right after the profile + * file is written). `uninstallForAgent` runs on DELETE /agents/:id + * (right before the profile file is removed). Both are best-effort + * from the caller's point of view: an install failure does not + * prevent the profile from being created, and an uninstall failure + * does not prevent the profile from being deleted. The HTTP response + * carries the outcome so the UI can surface it. + * + * Harness mapping is documented in HARNESS_TO_AGENT_ID below; two + * harnesses (Hermes, OpenClaw) are BrowserOS-internal and do not + * have a third-party config to write, so they short-circuit as a + * no-op success. + */ + +import type { AgentId, McpServerSpec } from 'agent-mcp-manager' +import { AgentNotSupportedError, ForeignEntryError } from 'agent-mcp-manager' +import { logger } from '../lib/logger' +import { getMcpManager } from '../lib/mcp-manager' +import type { Harness, StoredAgentProfile } from '../routes/agents/schemas' + +export interface InstallOutcome { + /** True iff the harness config was written successfully (or no-op for internal harnesses). */ + installed: boolean + /** + * Single-line human-readable message. Always present so the UI can + * surface the same string for success and failure. + */ + message: string + /** Filled when `installed` is true and the library wrote to a real file. */ + agent?: AgentId + configPath?: string +} + +/** + * Map the wizard's harness label to the upstream library's agent id. + * `null` means "no third-party config to write" (BrowserOS-internal + * harness); the install short-circuits as a successful no-op. + * + * If a mapping is wrong, change it here and every install/uninstall + * path picks up the new target. + */ +const HARNESS_TO_AGENT_ID: Record = { + 'Claude Code': 'claude-code', + 'Claude Desktop': 'claude-desktop', + Cursor: 'cursor', + 'VS Code': 'vscode', + Zed: 'zed', + Codex: 'codex', + 'Gemini CLI': 'gemini', + Hermes: null, + OpenClaw: null, +} + +/** + * Codex (and any future stdio-only target) cannot speak HTTP MCP, so + * we wrap the URL in `npx mcp-remote` the same way `apps/server` + * does. Other agents take the URL directly as `transport: 'http'`. + */ +const STDIO_ONLY: ReadonlySet = new Set(['codex']) + +function specFor(agentId: AgentId, mcpUrl: string): McpServerSpec { + if (STDIO_ONLY.has(agentId)) { + return { + transport: 'stdio', + command: 'npx', + args: ['mcp-remote', mcpUrl], + } + } + return { transport: 'http', url: mcpUrl } +} + +export async function installForAgent( + profile: Pick, +): Promise { + const agentId = HARNESS_TO_AGENT_ID[profile.harness] + if (agentId === null) { + return { + installed: true, + message: `${profile.harness} runs inside BrowserOS; no harness config to write.`, + } + } + const mgr = getMcpManager() + const spec = specFor(agentId, profile.mcpUrl) + try { + // `add` overwrites any existing entry with the same name so URL + // drift (port change between boots) gets caught on every install. + await mgr.add({ name: profile.slug, spec }) + const link = await mgr.link({ serverName: profile.slug, agent: agentId }) + logger.info('installed cockpit agent into harness', { + slug: profile.slug, + agent: agentId, + configPath: link.configPath, + }) + return { + installed: true, + message: `Endpoint registered with ${profile.harness}.`, + agent: agentId, + configPath: link.configPath, + } + } catch (err) { + return failure(err, profile.harness) + } +} + +export async function uninstallForAgent( + profile: Pick, +): Promise { + const agentId = HARNESS_TO_AGENT_ID[profile.harness] + if (agentId === null) { + return { + installed: false, + message: `${profile.harness} runs inside BrowserOS; nothing to uninstall.`, + } + } + const mgr = getMcpManager() + try { + await mgr.unlink({ serverName: profile.slug, agent: agentId }) + // Also drop the manifest entry so a future agent reusing the + // slug isn't blocked by a lingering record. + await mgr.remove({ serverName: profile.slug, unlinkFirst: false }) + logger.info('uninstalled cockpit agent from harness', { + slug: profile.slug, + agent: agentId, + }) + return { + installed: false, + message: `Endpoint unregistered from ${profile.harness}.`, + agent: agentId, + } + } catch (err) { + return failure(err, profile.harness) + } +} + +/** + * Re-sync the harness MCP config after a profile mutation that + * either rotated the slug or swapped the harness (or both). Always + * installs the new (harness, slug) FIRST so the harness has a + * working entry continuously; the stale entry under the old + * (harness, slug) is unlinked afterwards. + * + * No-op when both the harness and slug stayed the same. Returns the + * install + uninstall outcomes so callers can log them (today) or + * surface them in the response (later). + */ +export async function reconcileHarnessLink(input: { + before: Pick + after: Pick +}): Promise<{ + install: InstallOutcome | null + uninstall: InstallOutcome | null +}> { + const { before, after } = input + const harnessChanged = before.harness !== after.harness + const slugChanged = before.slug !== after.slug + if (!harnessChanged && !slugChanged) { + return { install: null, uninstall: null } + } + const install = await installForAgent(after) + const uninstall = await uninstallForAgent(before) + return { install, uninstall } +} + +function failure(err: unknown, harness: Harness): InstallOutcome { + if (err instanceof ForeignEntryError) { + logger.warn('harness entry exists but was not written by us', { + harness, + serverName: err.serverName, + agent: err.agent, + configPath: err.configPath, + }) + return { + installed: false, + message: `${harness} already has an entry under this name that we didn't write; remove it from the config and try again.`, + } + } + if (err instanceof AgentNotSupportedError) { + return { + installed: false, + message: `${harness} is not supported by the install layer (agent: ${err.agent}).`, + } + } + const message = err instanceof Error ? err.message : String(err) + logger.warn('harness install failed', { harness, error: message }) + return { + installed: false, + message: `Could not register endpoint with ${harness}: ${message}`, + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/services/permissions.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/services/permissions.ts new file mode 100644 index 000000000..16eef9f1d --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/services/permissions.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * In-process permission check used by the future browser executor + * (Phase 3) and run lifecycle (Phase 4). Returns the verdict for a + * single (agent, verb, domain) tuple plus the source so callers can + * tell the user WHY their action was clamped. + * + * Precedence (highest wins): + * 1. site-rule match -> block, source: 'site-rule' + * Rules are clamps: a matched rule overrides any agent verdict. + * Phase 5 treats every matching rule as a hard block; finer- + * grained per-rule verdicts can land later without churning the + * call site. + * 2. agent.approvals[verb] -> source: 'agent' + * 3. catalog defaultVerdict[verb] -> source: 'permission-default' + * 4. unknown verb -> block, source: 'permission-default' + * Defence in depth: a verb the catalog has never heard of is + * treated as restricted, not auto. + * + * There is NO HTTP route for this. Callers import and invoke + * directly; the unit tests are the only consumer until the executor + * lands. + */ + +import { + APPROVAL_CATEGORIES, + type ApprovalCategory, + type ApprovalVerdict, +} from '../lib/approval-catalog' +import * as agentsService from '../routes/agents/service' +import type { SiteRuleAction } from '../routes/site-rules/schemas' +import * as siteRulesService from '../routes/site-rules/service' + +export type CheckVerdict = 'auto' | 'ask' | 'block' +export type CheckSource = 'agent' | 'site-rule' | 'permission-default' + +export interface CheckInput { + agentId: string + verb: string + domain: string +} + +export interface CheckResult { + verdict: CheckVerdict + source: CheckSource +} + +/** + * Catalog verbs the user configures per agent map onto the coarser + * site-rule action space. `input` (click & type) is intentionally + * not domain-scoped: site rules clamp meaningful actions, not every + * keystroke. + * + * `admin` has no catalog counterpart but is a first-class site-rule + * action; we still want a verb that callers can use to ask "is this + * admin operation blocked on this domain?" so a configured admin + * rule attributes the block to `'site-rule'` instead of falling + * through to the unknown-verb safety default. + */ +const VERB_TO_RULE_ACTION: Record = { + submit: 'submit', + payment: 'payments', + delete: 'delete', + upload: 'upload', + navigate: 'navigate', + admin: 'admin', +} + +const CATALOG_BY_ID: Record = Object.fromEntries( + APPROVAL_CATEGORIES.map((category) => [category.id, category]), +) + +function normalize(verdict: ApprovalVerdict): CheckVerdict { + return verdict.toLowerCase() as CheckVerdict +} + +export async function check(input: CheckInput): Promise { + const { agentId, verb, domain } = input + + if (domain) { + const ruleAction = VERB_TO_RULE_ACTION[verb] + if (ruleAction) { + const matches = await siteRulesService.findMatching(domain, ruleAction) + if (matches.length > 0) { + return { verdict: 'block', source: 'site-rule' } + } + } + } + + const profile = await agentsService.getDetail(agentId) + const agentVerdict = profile?.approvals[verb] + if (agentVerdict) { + return { verdict: normalize(agentVerdict), source: 'agent' } + } + + const category = CATALOG_BY_ID[verb] + if (category) { + return { + verdict: normalize(category.defaultVerdict), + source: 'permission-default', + } + } + + return { verdict: 'block', source: 'permission-default' } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/src/shared/port.ts b/packages/browseros-agent/apps/agent-mcp-interface/src/shared/port.ts new file mode 100644 index 000000000..9e886f774 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/src/shared/port.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Production API port for the cockpit. The cockpit's Hono app mounts + * inside `@browseros/server`'s HTTP runtime (port 9100 by default) + * under the `/cockpit` prefix, so the UI base URL is + * `http://127.0.0.1:9100/cockpit`. + * + * The standalone `src/main.ts` still binds on `DEV_STANDALONE_PORT` + * (9200) for solo dev and tests, but production traffic goes through + * `apps/server`. + * + * Existing BrowserOS port allocations (per + * apps/server/.env.example): CDP=9000, server=9100, extension=9300. + */ +export const PROD_API_PORT = 9100 + +/** Mount prefix the cockpit's app is routed under inside apps/server. */ +export const COCKPIT_MOUNT_PREFIX = '/cockpit' + +/** Standalone dev port for `src/main.ts` when running detached. */ +export const DEV_STANDALONE_PORT = 9200 + +/** + * Default CDP port the cockpit dials when attaching to the BrowserOS + * Chromium. Lives in IANA's dynamic / private range (49152-65535) + * so it cannot collide with a registered service, and is not a round + * number so a hand-rolled local script is unlikely to pick the same + * value. Override via `BROWSEROS_COCKPIT_CDP_PORT`. + * + * Important: this is the port BrowserOS's Chromium exposes its + * DevTools on, not a port the cockpit itself opens. Until the + * BrowserOS browser shell defaults to the same value, the env var + * is the bridge — set it to whatever DevTools port BrowserOS is + * currently bound on. + */ +export const COCKPIT_CDP_PORT_DEFAULT = 49337 diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/_helpers/stub-mcp-manager.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/_helpers/stub-mcp-manager.ts new file mode 100644 index 000000000..4bb9f87ed --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/_helpers/stub-mcp-manager.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * In-memory no-op `McpManager` for tests. Real agent-mcp-manager + * writes to per-user config files (`~/.claude.json`, `~/.cursor/ + * mcp.json`, ...); we never want tests to touch those, so every test + * that runs through `withTempBrowserosDir` gets this stub installed + * by default. + * + * Tests that need to assert on install behaviour can grab a fresh + * stub via `createStubMcpManager()` and inspect its `calls` array. + */ + +import type { + AddServerOptions, + AddServerResult, + LinkServerOptions, + LinkServerResult, + McpManager, + RemoveServerOptions, + UnlinkServerOptions, + UnlinkServerResult, +} from 'agent-mcp-manager' + +export interface StubCall { + method: + | 'add' + | 'link' + | 'unlink' + | 'remove' + | 'listServers' + | 'listLinks' + | 'rescan' + payload: unknown +} + +export interface StubMcpManager extends McpManager { + readonly calls: StubCall[] + reset(): void +} + +export function createStubMcpManager(): StubMcpManager { + const calls: StubCall[] = [] + return { + calls, + reset(): void { + calls.length = 0 + }, + async add(opts: AddServerOptions): Promise { + calls.push({ method: 'add', payload: opts }) + return { name: opts.name, created: true } + }, + async link(opts: LinkServerOptions): Promise { + calls.push({ method: 'link', payload: opts }) + return { + serverName: opts.serverName, + agent: opts.agent, + configPath: opts.configPath ?? `/tmp/stub-${opts.agent}.json`, + created: true, + } + }, + async unlink(opts: UnlinkServerOptions): Promise { + calls.push({ method: 'unlink', payload: opts }) + return { + serverName: opts.serverName, + agent: opts.agent, + configPath: opts.configPath ?? `/tmp/stub-${opts.agent}.json`, + removed: true, + } + }, + async remove(opts: RemoveServerOptions): Promise { + calls.push({ method: 'remove', payload: opts }) + }, + async listServers() { + calls.push({ method: 'listServers', payload: {} }) + return [] + }, + async listLinks() { + calls.push({ method: 'listLinks', payload: {} }) + return [] + }, + async rescan() { + calls.push({ method: 'rescan', payload: {} }) + return { verified: [], drifted: [], broken: [], unmanaged: [] } + }, + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/_helpers/temp-browseros-dir.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/_helpers/temp-browseros-dir.ts new file mode 100644 index 000000000..0fdc0f8d7 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/_helpers/temp-browseros-dir.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Test helper: redirects the interface package's storage root to a + * fresh tmp directory so each test is isolated. The override is set + * on the shared `env` object (read once at module load), then nudged + * back to its prior value after the test. + * + * As a safety net every test also gets a no-op `McpManager` stub + * swapped in so harness-install side-effects in `agents.create` / + * `agents.remove` never touch the user's real `~/.claude.json`. Tests + * that want to assert on install behaviour can override the stub + * inside the body by calling `setMcpManagerForTesting(myStub)`. + * + * Use as a wrapper: + * + * await withTempBrowserosDir(async () => { + * // body runs against an isolated + * }) + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { env } from '../../src/env' +import { + resetMcpManagerForTesting, + setMcpManagerForTesting, +} from '../../src/lib/mcp-manager' +import { createStubMcpManager } from './stub-mcp-manager' + +export async function withTempBrowserosDir( + body: (dir: string) => Promise, +): Promise { + const dir = await mkdtemp(join(tmpdir(), 'browseros-mcp-interface-')) + const prior = env.browserosDirOverride + env.browserosDirOverride = dir + setMcpManagerForTesting(createStubMcpManager()) + try { + return await body(dir) + } finally { + env.browserosDirOverride = prior + // Drop the stub so any test that didn't use `withTempBrowserosDir` + // gets a fresh real-or-injected manager next time. + resetMcpManagerForTesting() + await rm(dir, { recursive: true, force: true }) + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/async-mutex.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/async-mutex.test.ts new file mode 100644 index 000000000..a14908242 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/async-mutex.test.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, test } from 'bun:test' +import { AsyncMutex } from '../../src/lib/async-mutex' + +describe('AsyncMutex', () => { + test('serialises tasks submitted concurrently', async () => { + const mutex = new AsyncMutex() + const log: string[] = [] + const make = (name: string, delayMs: number) => () => + new Promise((resolve) => { + log.push(`start:${name}`) + setTimeout(() => { + log.push(`done:${name}`) + resolve(name) + }, delayMs) + }) + + const results = await Promise.all([ + mutex.run(make('a', 30)), + mutex.run(make('b', 5)), + mutex.run(make('c', 5)), + ]) + + expect(results).toEqual(['a', 'b', 'c']) + // Each task fully completes before the next one starts. + expect(log).toEqual([ + 'start:a', + 'done:a', + 'start:b', + 'done:b', + 'start:c', + 'done:c', + ]) + }) + + test('a rejected task does not block subsequent ones', async () => { + const mutex = new AsyncMutex() + const ran: string[] = [] + const rejected = mutex.run(async () => { + ran.push('reject') + throw new Error('boom') + }) + const ok = mutex.run(async () => { + ran.push('after') + return 'ok' + }) + + expect(rejected).rejects.toThrow('boom') + expect(await ok).toBe('ok') + expect(ran).toEqual(['reject', 'after']) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/browser-bootstrap.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/browser-bootstrap.test.ts new file mode 100644 index 000000000..d12b69fd6 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/browser-bootstrap.test.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Bootstrap covers the connect / disconnect contract the cockpit + * relies on when running standalone. The real `CdpBackend` is + * injected via `BrowserBootstrapDeps`, so this test never opens a + * socket. + */ + +import { describe, expect, test } from 'bun:test' +import type { BrowserSession } from '@browseros/server/browser/core/session' +import { + bootstrapBrowserosBrowser, + type CdpClient, +} from '../../src/lib/browser-bootstrap' + +interface StubCdp extends CdpClient { + readonly connectCalls: number + readonly disconnectCalls: number +} + +function makeStubCdp(opts: { connectThrows?: Error } = {}): StubCdp { + let connectCalls = 0 + let disconnectCalls = 0 + return { + get connectCalls() { + return connectCalls + }, + get disconnectCalls() { + return disconnectCalls + }, + connect: async () => { + connectCalls++ + if (opts.connectThrows) throw opts.connectThrows + }, + disconnect: async () => { + disconnectCalls++ + }, + } +} + +const fakeSession = { tag: 'fake-session' } as unknown as BrowserSession + +describe('bootstrapBrowserosBrowser', () => { + test('connects, builds a session, and returns a disconnect() that drops the cdp', async () => { + const cdp = makeStubCdp() + const seenCdps: CdpClient[] = [] + const result = await bootstrapBrowserosBrowser({ + inject: { + cdpFactory: () => cdp, + buildSession: (c) => { + seenCdps.push(c) + return fakeSession + }, + }, + }) + expect(result).not.toBeNull() + expect(result?.session).toBe(fakeSession) + expect(cdp.connectCalls).toBe(1) + expect(seenCdps).toEqual([cdp]) + await result?.disconnect() + expect(cdp.disconnectCalls).toBe(1) + }) + + test('returns null when the cdp connect throws and never builds a session', async () => { + const cdp = makeStubCdp({ connectThrows: new Error('ECONNREFUSED') }) + let buildSessionCalls = 0 + const result = await bootstrapBrowserosBrowser({ + inject: { + cdpFactory: () => cdp, + buildSession: () => { + buildSessionCalls++ + return fakeSession + }, + }, + }) + expect(result).toBeNull() + expect(cdp.connectCalls).toBe(1) + expect(buildSessionCalls).toBe(0) + }) + + test('disconnect() swallows underlying errors so callers can call it from a signal handler', async () => { + const cdp: CdpClient = { + connect: async () => undefined, + disconnect: async () => { + throw new Error('socket already gone') + }, + } + const result = await bootstrapBrowserosBrowser({ + inject: { + cdpFactory: () => cdp, + buildSession: () => fakeSession, + }, + }) + expect(result).not.toBeNull() + // The disconnect must not throw — the signal-handler path in + // main.ts relies on this so it can always reach process.exit. + await expect(result?.disconnect()).resolves.toBeUndefined() + }) + + test('cdpFactory is invoked with env.cdpPort so a misconfigured port is observable', async () => { + const seenPorts: number[] = [] + const cdp = makeStubCdp() + await bootstrapBrowserosBrowser({ + inject: { + cdpFactory: (port) => { + seenPorts.push(port) + return cdp + }, + buildSession: () => fakeSession, + }, + }) + expect(seenPorts).toHaveLength(1) + expect(seenPorts[0]).toBeGreaterThan(0) + expect(seenPorts[0]).toBeLessThanOrEqual(65535) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/match-domain.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/match-domain.test.ts new file mode 100644 index 000000000..81f58bf6b --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/match-domain.test.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, test } from 'bun:test' +import { matchDomain } from '../../src/lib/match-domain' + +describe('matchDomain', () => { + test('exact match is case insensitive', () => { + expect(matchDomain('stripe.com', 'stripe.com')).toBe(true) + expect(matchDomain('STRIPE.com', 'stripe.com')).toBe(true) + expect(matchDomain('stripe.com', 'STRIPE.COM')).toBe(true) + }) + + test('exact match rejects unrelated domains', () => { + expect(matchDomain('stripe.com', 'evil-stripe.com')).toBe(false) + expect(matchDomain('stripe.com', 'stripe.com.evil')).toBe(false) + expect(matchDomain('stripe.com', 'api.stripe.com')).toBe(false) + }) + + test('leading wildcard matches subdomains, not the apex', () => { + expect(matchDomain('*.example.com', 'foo.example.com')).toBe(true) + expect(matchDomain('*.example.com', 'a.b.example.com')).toBe(true) + // The apex (no subdomain label) must NOT match: `.example.com` + // would normalise to `example.com` which a user wanting to also + // cover the apex should add as a separate rule. + expect(matchDomain('*.example.com', 'example.com')).toBe(false) + }) + + test('trailing wildcard matches anything after the dot', () => { + expect(matchDomain('admin.*', 'admin.example.com')).toBe(true) + expect(matchDomain('admin.*', 'admin.foo')).toBe(true) + // Trailing wildcard requires at least one character after the dot. + expect(matchDomain('admin.*', 'admin.')).toBe(false) + // The bare label must NOT match the wildcard expansion. + expect(matchDomain('admin.*', 'admin')).toBe(false) + }) + + test('bare star matches any non-empty domain', () => { + expect(matchDomain('*', 'example.com')).toBe(true) + expect(matchDomain('*', 'a')).toBe(true) + expect(matchDomain('*', '')).toBe(false) + }) + + test('star in the middle works for vendor.product patterns', () => { + expect(matchDomain('app.*.com', 'app.stripe.com')).toBe(true) + expect(matchDomain('app.*.com', 'app.foo.bar.com')).toBe(true) + expect(matchDomain('app.*.com', 'web.stripe.com')).toBe(false) + }) + + test('empty pattern never matches', () => { + expect(matchDomain('', 'example.com')).toBe(false) + expect(matchDomain('', '')).toBe(false) + }) + + test('regex metacharacters in patterns are treated as literals', () => { + // `.` matters: `a.b` must not match `aXb`. + expect(matchDomain('a.b', 'a.b')).toBe(true) + expect(matchDomain('a.b', 'aXb')).toBe(false) + // `+` and `(` should not be interpreted as regex operators. + expect(matchDomain('foo+bar.com', 'foo+bar.com')).toBe(true) + expect(matchDomain('foo+bar.com', 'foobar.com')).toBe(false) + expect(matchDomain('a(b).com', 'a(b).com')).toBe(true) + }) + + test('domain comparison is case insensitive', () => { + expect(matchDomain('*.Example.com', 'foo.example.com')).toBe(true) + expect(matchDomain('*.example.com', 'FOO.example.COM')).toBe(true) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/migrate-mcp-urls.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/migrate-mcp-urls.test.ts new file mode 100644 index 000000000..5311db9a1 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/migrate-mcp-urls.test.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, test } from 'bun:test' +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { setMcpManagerForTesting } from '../../src/lib/mcp-manager' +import { migrateMcpUrls } from '../../src/lib/migrate-mcp-urls' +import { readJson } from '../../src/lib/storage' +import type { NewAgentValues } from '../../src/routes/agents/schemas' +import { storedAgentProfileSchema } from '../../src/routes/agents/schemas' +import * as agents from '../../src/routes/agents/service' +import { createStubMcpManager } from '../_helpers/stub-mcp-manager' +import { withTempBrowserosDir } from '../_helpers/temp-browseros-dir' + +function makeInput(overrides: Partial = {}): NewAgentValues { + return { + name: 'Original', + harness: 'Claude Desktop', + loginMode: 'profile', + selectedSites: [], + approvals: { + submit: 'Ask', + payment: 'Block', + delete: 'Ask', + upload: 'Ask', + navigate: 'Auto', + input: 'Auto', + }, + aclRuleIds: [], + customAclRules: [], + ...overrides, + } +} + +describe('migrateMcpUrls', () => { + test('rewrites mcpUrl when the recomputed URL differs from the stored one', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeInput({ name: 'Cowork' })) + // Sanity: stored mcpUrl is whatever buildMcpUrl produced at create + // (in the test environment that's `http://127.0.0.1:9200/mcp/cowork`). + expect(created.mcpUrl).toContain('/mcp/cowork') + + // New shape: same slug, different host + prefix. + const newBuilder = (slug: string) => + `http://127.0.0.1:9100/cockpit/mcp/${slug}` + const result = await migrateMcpUrls(newBuilder) + expect(result.migrated).toBe(1) + expect(result.skipped).toBe(0) + expect(result.failed).toBe(0) + + const stored = await readJson( + `agents/${created.id}.json`, + storedAgentProfileSchema, + ) + expect(stored.mcpUrl).toBe('http://127.0.0.1:9100/cockpit/mcp/cowork') + }) + }) + + test('skips a profile whose stored URL already matches the new shape', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeInput({ name: 'Stable' })) + const sameBuilder = (slug: string) => + created.mcpUrl.replace(/\/[^/]*$/, `/${slug}`) + const result = await migrateMcpUrls(sameBuilder) + expect(result.migrated).toBe(0) + expect(result.skipped).toBe(1) + expect(result.failed).toBe(0) + }) + }) + + test('re-installs the harness entry per migrated row', async () => { + await withTempBrowserosDir(async () => { + const stub = createStubMcpManager() + setMcpManagerForTesting(stub) + const created = await agents.create(makeInput({ name: 'Reinstall' })) + stub.reset() + const newBuilder = (slug: string) => + `http://127.0.0.1:9100/cockpit/mcp/${slug}` + await migrateMcpUrls(newBuilder) + const methods = stub.calls.map((c) => c.method) + // Migration: uninstall (old entry) then install (new URL). + expect(methods).toContain('unlink') + expect(methods).toContain('add') + expect(methods).toContain('link') + const addCall = stub.calls.find((c) => c.method === 'add') + expect(addCall?.payload).toMatchObject({ + name: created.slug, + spec: { + transport: 'http', + url: `http://127.0.0.1:9100/cockpit/mcp/${created.slug}`, + }, + }) + }) + }) + + test('a corrupt profile file is logged + skipped without aborting the sweep', async () => { + await withTempBrowserosDir(async (dir) => { + const ok = await agents.create(makeInput({ name: 'Healthy' })) + // Drop a garbage file next to the valid one. + await writeFile( + join(dir, 'mcp-interface/agents', 'broken.json'), + '{ this is not valid json', + 'utf8', + ) + const newBuilder = (slug: string) => + `http://127.0.0.1:9100/cockpit/mcp/${slug}` + const result = await migrateMcpUrls(newBuilder) + expect(result.migrated).toBe(1) + expect(result.failed).toBe(1) + // The healthy profile got its URL rewritten. + const stored = await readJson( + `agents/${ok.id}.json`, + storedAgentProfileSchema, + ) + expect(stored.mcpUrl).toContain('/cockpit/mcp/') + }) + }) + + test('an empty agents directory returns zero counts and does not throw', async () => { + await withTempBrowserosDir(async () => { + const result = await migrateMcpUrls( + (slug) => `http://127.0.0.1:9100/cockpit/mcp/${slug}`, + ) + expect(result).toEqual({ migrated: 0, skipped: 0, failed: 0 }) + }) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/storage.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/storage.test.ts new file mode 100644 index 000000000..6be3c10dd --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/storage.test.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, test } from 'bun:test' +import { existsSync } from 'node:fs' +import { readdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import { + ensureDir, + fileExists, + listFiles, + readJson, + removeFile, + StorageCorruptError, + StorageInvalidPathError, + StorageNotFoundError, + writeJson, +} from '../../src/lib/storage' +import { withTempBrowserosDir } from '../_helpers/temp-browseros-dir' + +const sampleSchema = z.object({ + name: z.string(), + ok: z.boolean(), +}) + +describe('storage', () => { + test('writeJson then readJson round-trips through the schema', async () => { + await withTempBrowserosDir(async () => { + await writeJson('sample.json', { name: 'one', ok: true }, sampleSchema) + const read = await readJson('sample.json', sampleSchema) + expect(read).toEqual({ name: 'one', ok: true }) + }) + }) + + test('writeJson creates parent directories', async () => { + await withTempBrowserosDir(async (dir) => { + await writeJson( + 'nested/dir/sample.json', + { name: 'nested', ok: true }, + sampleSchema, + ) + expect( + existsSync(join(dir, 'mcp-interface/nested/dir/sample.json')), + ).toBe(true) + }) + }) + + test('writeJson is atomic: the .tmp file is renamed, not left behind', async () => { + await withTempBrowserosDir(async (dir) => { + await writeJson('atomic.json', { name: 'a', ok: true }, sampleSchema) + const entries = await readdir(join(dir, 'mcp-interface')) + expect(entries).toContain('atomic.json') + expect(entries.some((entry) => entry.endsWith('.tmp'))).toBe(false) + }) + }) + + test('readJson throws StorageNotFoundError on missing file', async () => { + await withTempBrowserosDir(async () => { + expect(readJson('ghost.json', sampleSchema)).rejects.toBeInstanceOf( + StorageNotFoundError, + ) + }) + }) + + test('readJson throws StorageCorruptError when JSON is invalid', async () => { + await withTempBrowserosDir(async (dir) => { + const root = join(dir, 'mcp-interface') + await ensureDir('.') + await writeFile(join(root, 'broken.json'), '{not-json', 'utf8') + expect(readJson('broken.json', sampleSchema)).rejects.toBeInstanceOf( + StorageCorruptError, + ) + }) + }) + + test('readJson throws StorageCorruptError when schema rejects the value', async () => { + await withTempBrowserosDir(async (dir) => { + const root = join(dir, 'mcp-interface') + await ensureDir('.') + await writeFile( + join(root, 'wrong-shape.json'), + JSON.stringify({ name: 1, ok: 'no' }), + 'utf8', + ) + expect(readJson('wrong-shape.json', sampleSchema)).rejects.toBeInstanceOf( + StorageCorruptError, + ) + }) + }) + + test('writeJson refuses values that do not satisfy the schema', async () => { + await withTempBrowserosDir(async () => { + // biome-ignore lint/suspicious/noExplicitAny: deliberate invalid input for the test + const bad = { name: 5, ok: 'not a bool' } as any + expect( + writeJson('rejected.json', bad, sampleSchema), + ).rejects.toBeInstanceOf(StorageCorruptError) + }) + }) + + test('removeFile deletes an existing file and returns true', async () => { + await withTempBrowserosDir(async () => { + await writeJson('to-delete.json', { name: 'd', ok: true }, sampleSchema) + expect(await removeFile('to-delete.json')).toBe(true) + expect(await fileExists('to-delete.json')).toBe(false) + }) + }) + + test('removeFile returns false when the file does not exist', async () => { + await withTempBrowserosDir(async () => { + expect(await removeFile('never-existed.json')).toBe(false) + }) + }) + + test('listFiles defaults to .json and filters non-matching entries', async () => { + await withTempBrowserosDir(async (dir) => { + const root = join(dir, 'mcp-interface', 'list-test') + await ensureDir('list-test') + await writeFile(join(root, 'a.json'), '{"name":"a","ok":true}', 'utf8') + await writeFile(join(root, 'b.json'), '{"name":"b","ok":true}', 'utf8') + await writeFile(join(root, 'c.txt'), 'unrelated', 'utf8') + const names = await listFiles('list-test') + expect(names.sort()).toEqual(['a.json', 'b.json']) + }) + }) + + test('listFiles returns [] when the directory does not exist', async () => { + await withTempBrowserosDir(async () => { + expect(await listFiles('missing-dir')).toEqual([]) + }) + }) + + test('relative paths cannot escape the interface root', async () => { + await withTempBrowserosDir(async () => { + expect(() => + writeJson('../escape.json', { name: 'e', ok: true }, sampleSchema), + ).toThrow(StorageInvalidPathError) + expect(() => readJson('../escape.json', sampleSchema)).toThrow( + StorageInvalidPathError, + ) + }) + }) + + test('absolute paths are rejected', async () => { + await withTempBrowserosDir(async () => { + expect(() => readJson('/etc/passwd', sampleSchema)).toThrow( + StorageInvalidPathError, + ) + }) + }) + + test('lateral traversal that stays inside the interface root is still rejected', async () => { + await withTempBrowserosDir(async () => { + // `agents/../config.json` normalises to `config.json` which sits + // INSIDE the interface root but escapes the intended subdirectory. + // The guard must catch the raw `..` before normalize collapses it. + expect(() => readJson('agents/../config.json', sampleSchema)).toThrow( + StorageInvalidPathError, + ) + expect(() => + writeJson( + 'agents/../config.json', + { name: 'x', ok: true }, + sampleSchema, + ), + ).toThrow(StorageInvalidPathError) + expect(() => removeFile('agents/../config.json')).toThrow( + StorageInvalidPathError, + ) + }) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/extract-page-id.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/extract-page-id.test.ts new file mode 100644 index 000000000..cfe8a6a73 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/extract-page-id.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test' +import { extractPageId } from '../../../src/lib/tab-activity/extract-page-id' + +describe('extractPageId', () => { + it('returns the page id for every tool that takes a page arg', () => { + for (const tool of [ + 'act', + 'diff', + 'download', + 'evaluate', + 'grep', + 'navigate', + 'pdf', + 'read', + 'screenshot', + 'snapshot', + 'tabs', + 'upload', + 'wait', + ]) { + expect(extractPageId(tool, { page: 7 })).toBe(7) + } + }) + + it('returns null for tools without a page arg', () => { + expect(extractPageId('tab_groups', { page: 7 })).toBeNull() + expect(extractPageId('windows', { page: 7 })).toBeNull() + expect(extractPageId('run', { page: 7 })).toBeNull() + }) + + it('returns null for unknown tools', () => { + expect(extractPageId('completely_unknown', { page: 1 })).toBeNull() + }) + + it('returns null when page is missing', () => { + expect(extractPageId('navigate', { url: 'https://example.com' })).toBeNull() + expect(extractPageId('navigate', {})).toBeNull() + }) + + it('returns null when page is not a number', () => { + expect(extractPageId('navigate', { page: '7' })).toBeNull() + expect(extractPageId('navigate', { page: null })).toBeNull() + expect(extractPageId('navigate', { page: undefined })).toBeNull() + }) + + it('returns null for non-integer page', () => { + expect(extractPageId('navigate', { page: 1.5 })).toBeNull() + }) + + it('returns null for non-positive page', () => { + expect(extractPageId('navigate', { page: 0 })).toBeNull() + expect(extractPageId('navigate', { page: -1 })).toBeNull() + }) + + it('returns null for non-object args', () => { + expect(extractPageId('navigate', null)).toBeNull() + expect(extractPageId('navigate', undefined)).toBeNull() + expect(extractPageId('navigate', 'page=1')).toBeNull() + expect(extractPageId('navigate', 42)).toBeNull() + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/registry.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/registry.test.ts new file mode 100644 index 000000000..13f29b2a8 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/lib/tab-activity/registry.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import type { BrowserSession } from '@browseros/server/browser/core/session' +import { + ACTIVE_WINDOW_MS, + createTabActivityRegistry, + type TabActivityRegistry, +} from '../../../src/lib/tab-activity/registry' + +interface FakePageInfo { + targetId: string + url: string + title: string +} + +function makeSession(pages: Map): BrowserSession { + return { + pages: { + getInfo: (pageId: number) => pages.get(pageId) ?? undefined, + }, + } as unknown as BrowserSession +} + +describe('TabActivityRegistry', () => { + let pages: Map + let session: BrowserSession + let nowMs: number + let registry: TabActivityRegistry + + beforeEach(() => { + pages = new Map() + session = makeSession(pages) + nowMs = 1_000_000 + registry = createTabActivityRegistry({ + getSession: () => session, + now: () => nowMs, + }) + }) + + it('records a tool dispatch and surfaces it via snapshot', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + const snap = registry.snapshot() + expect(snap).toHaveLength(1) + expect(snap[0]).toMatchObject({ + targetId: 't1', + pageId: 1, + url: 'https://example.com/', + title: 'Ex', + agentId: 'a1', + slug: 'finance-ops', + lastToolName: 'navigate', + status: 'active', + }) + }) + + it('updates an existing record rather than appending a duplicate', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + nowMs += 1000 + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'snapshot', + }) + const snap = registry.snapshot() + expect(snap).toHaveLength(1) + expect(snap[0].lastToolName).toBe('snapshot') + expect(snap[0].lastToolAt).toBe(1_001_000) + }) + + it('marks records active within the window and idle outside it', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + expect(registry.snapshot()[0].status).toBe('active') + nowMs += ACTIVE_WINDOW_MS - 1 + expect(registry.snapshot()[0].status).toBe('active') + nowMs += 2 + expect(registry.snapshot()[0].status).toBe('idle') + }) + + it('evicts records whose pageId no longer maps to the original targetId', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + expect(registry.size()).toBe(1) + // The tab closes, pageId 1 is reused by a fresh tab with a new targetId. + pages.set(1, { targetId: 't2-different', url: 'about:blank', title: '' }) + expect(registry.snapshot()).toHaveLength(0) + expect(registry.size()).toBe(0) + }) + + it('evicts records whose pageId no longer exists at all', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + pages.delete(1) + expect(registry.snapshot()).toHaveLength(0) + expect(registry.size()).toBe(0) + }) + + it('returns an empty snapshot when no session is connected', () => { + pages.set(1, { targetId: 't1', url: 'https://example.com/', title: 'Ex' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + const detached = createTabActivityRegistry({ + getSession: () => null, + now: () => nowMs, + }) + detached.recordTool({ + agentId: 'a1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + expect(detached.snapshot()).toEqual([]) + }) + + it('keeps separate records per target id', () => { + pages.set(1, { targetId: 't1', url: 'https://a.com/', title: 'A' }) + pages.set(2, { targetId: 't2', url: 'https://b.com/', title: 'B' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + nowMs += 100 + registry.recordTool({ + agentId: 'a2', + slug: 'travel', + pageId: 2, + targetId: 't2', + toolName: 'read', + }) + const snap = registry.snapshot() + expect(snap).toHaveLength(2) + expect(snap.map((r) => r.targetId)).toEqual(['t2', 't1']) + }) + + it('sorts the snapshot by lastToolAt descending', () => { + pages.set(1, { targetId: 't1', url: 'https://a.com/', title: 'A' }) + pages.set(2, { targetId: 't2', url: 'https://b.com/', title: 'B' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + nowMs += 100 + registry.recordTool({ + agentId: 'a2', + slug: 'travel', + pageId: 2, + targetId: 't2', + toolName: 'read', + }) + nowMs += 100 + registry.recordTool({ + agentId: 'a1', + slug: 'finance', + pageId: 1, + targetId: 't1', + toolName: 'snapshot', + }) + const snap = registry.snapshot() + expect(snap.map((r) => r.targetId)).toEqual(['t1', 't2']) + }) + + it('last write wins on agent attribution when two agents touch the same tab', () => { + pages.set(1, { targetId: 't1', url: 'https://a.com/', title: 'A' }) + registry.recordTool({ + agentId: 'a1', + slug: 'finance', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + nowMs += 100 + registry.recordTool({ + agentId: 'a2', + slug: 'travel', + pageId: 1, + targetId: 't1', + toolName: 'snapshot', + }) + const snap = registry.snapshot() + expect(snap).toHaveLength(1) + expect(snap[0].agentId).toBe('a2') + expect(snap[0].slug).toBe('travel') + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/mcp/integration.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/mcp/integration.test.ts new file mode 100644 index 000000000..ebf9ccd8e --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/mcp/integration.test.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * MCP route integration smoke. Spins the SDK's Client against a + * fetch override that routes every request through Hono's + * `app.fetch`, so we never bind a port. Each test gets a fresh + * tmp `` so created agents don't leak. + * + * The tools surface is the real `@browseros/server` catalogue. Tool + * dispatches that pass the permission gate hit the + * "session not connected" short-circuit because the cockpit's + * runtime is not yet bound to a live Chromium (that happens in a + * later commit). The permission-gate paths (Auto / Block / Ask) are + * fully exercisable without a session. + */ + +import { describe, expect, test } from 'bun:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import type { NewAgentValues } from '../../src/routes/agents/schemas' +import * as agents from '../../src/routes/agents/service' +import app from '../../src/server' +import { withTempBrowserosDir } from '../_helpers/temp-browseros-dir' + +const REAL_CATALOGUE = [ + 'act', + 'diff', + 'download', + 'evaluate', + 'grep', + 'navigate', + 'pdf', + 'read', + 'run', + 'screenshot', + 'snapshot', + 'tab_groups', + 'tabs', + 'upload', + 'wait', + 'windows', +] as const + +function makeAgentInput(): NewAgentValues { + return { + name: 'Cowork . MCP smoke', + harness: 'Claude Desktop', + loginMode: 'profile', + selectedSites: [], + approvals: { + submit: 'Ask', + payment: 'Block', + delete: 'Ask', + upload: 'Ask', + navigate: 'Auto', + input: 'Auto', + }, + aclRuleIds: [], + customAclRules: [], + } +} + +async function connectedClientFor(slug: string): Promise { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost/mcp/${slug}`), + { + fetch: ((input, init) => + app.fetch(new Request(input, init))) as typeof fetch, + }, + ) + const client = new Client( + { name: 'test-client', version: '0.0.1' }, + { capabilities: {} }, + ) + await client.connect(transport) + return client +} + +describe('/mcp/:slug route', () => { + test('deleted agent slug starts 404-ing immediately on the next request', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeAgentInput()) + const before = await app.fetch( + new Request(`http://localhost/mcp/${created.slug}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '0' }, + }, + }), + }), + ) + expect(before.status).toBe(200) + + await agents.remove(created.id) + + const after = await app.fetch( + new Request(`http://localhost/mcp/${created.slug}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '0' }, + }, + }), + }), + ) + expect(after.status).toBe(404) + }) + }) + + test('unknown slug returns 404 at the route layer', async () => { + await withTempBrowserosDir(async () => { + const res = await app.fetch( + new Request('http://localhost/mcp/never-existed', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'curl', version: '0' }, + }, + }), + }), + ) + expect(res.status).toBe(404) + }) + }) + + test('tools/list returns the real ten-tool catalogue', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeAgentInput()) + const client = await connectedClientFor(created.slug) + const tools = await client.listTools() + const names = tools.tools.map((t) => t.name).sort() + expect(names).toEqual([...REAL_CATALOGUE]) + await client.close() + }) + }) + + test('navigate on the Auto path short-circuits with "session not connected"', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeAgentInput()) + const client = await connectedClientFor(created.slug) + const result = await client.callTool({ + name: 'navigate', + arguments: { page: 0, action: 'url', url: 'https://docs.google.com' }, + }) + expect(result.isError).toBe(true) + const content = result.content as Array<{ type: string; text: string }> + expect(content[0].text).toContain('browser session not connected') + await client.close() + }) + }) + + test('navigate on a site-rule blocked domain (Block verdict) returns a structured error', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeAgentInput()) + const { add: addSiteRule } = await import( + '../../src/routes/site-rules/service' + ) + await addSiteRule({ + label: 'no google', + domain: '*.google.com', + action: 'navigate', + }) + const client = await connectedClientFor(created.slug) + const result = await client.callTool({ + name: 'navigate', + arguments: { page: 0, action: 'url', url: 'https://docs.google.com' }, + }) + expect(result.isError).toBe(true) + const content = result.content as Array<{ type: string; text: string }> + expect(content[0].text).toContain('blocked by site-rule') + expect(content[0].text).toContain('navigate') + expect(content[0].text).toContain('docs.google.com') + await client.close() + }) + }) + + test('navigate refuses javascript:, file:, and data: URIs at the cockpit layer', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeAgentInput()) + const client = await connectedClientFor(created.slug) + for (const url of [ + 'javascript:alert(1)', + 'file:///etc/passwd', + 'data:text/html,', + ]) { + const result = await client.callTool({ + name: 'navigate', + arguments: { page: 0, action: 'url', url }, + }) + expect(result.isError).toBe(true) + const content = result.content as Array<{ type: string; text: string }> + expect(content[0].text).toContain('only http(s) is allowed') + } + await client.close() + }) + }) + + test('a verb whose agent verdict is Ask returns the deferred-approval error', async () => { + await withTempBrowserosDir(async () => { + const askAgent = await agents.create({ + ...makeAgentInput(), + name: 'Cowork . MCP ask', + approvals: { + ...makeAgentInput().approvals, + navigate: 'Ask', + }, + }) + const client = await connectedClientFor(askAgent.slug) + const result = await client.callTool({ + name: 'navigate', + arguments: { page: 0, action: 'url', url: 'https://docs.google.com' }, + }) + expect(result.isError).toBe(true) + const content = result.content as Array<{ type: string; text: string }> + expect(content[0].text).toContain('approval required for navigate') + await client.close() + }) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/agents/routes.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/agents/routes.test.ts new file mode 100644 index 000000000..958bd19f4 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/agents/routes.test.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Integration tests for the /agents routes. Hits the live Hono app + * via the typed client (`hc`) but routes everything through + * `app.fetch` so there is no real socket bind. Each test gets its + * own tmp `` so state doesn't leak between cases. + */ + +import { describe, expect, test } from 'bun:test' +import { hc } from 'hono/client' +import type { + AgentProfileSummary, + NewAgentValues, + StoredAgentProfile, +} from '../../../src/routes/agents/schemas' +import app, { type AppType } from '../../../src/server' +import { withTempBrowserosDir } from '../../_helpers/temp-browseros-dir' + +function client() { + // hc only needs a base URL to construct request paths; the fetch + // override sends every request to `app.fetch` so no port is bound. + return hc('http://localhost', { + fetch: (input, init) => app.fetch(new Request(input, init)), + }) +} + +function makeBody(overrides: Partial = {}): NewAgentValues { + return { + name: 'Cowork . Finance ops', + harness: 'Claude Desktop', + loginMode: 'profile', + selectedSites: [], + approvals: { + submit: 'Ask', + payment: 'Block', + delete: 'Ask', + upload: 'Ask', + navigate: 'Ask', + input: 'Auto', + }, + aclRuleIds: ['wire-transfers'], + customAclRules: [], + ...overrides, + } +} + +describe('/agents routes', () => { + test('full lifecycle: create → list → detail → update → regenerate → delete', async () => { + await withTempBrowserosDir(async () => { + const api = client() + + // create + const createRes = await api.agents.$post({ json: makeBody() }) + expect(createRes.status).toBe(201) + const created = await createRes.json() + expect(created.harness).toBe('Claude Desktop') + expect(created.slug).toBe('cowork-finance-ops') + expect(created.mcpUrl).toMatch(/\/mcp\/cowork-finance-ops$/) + expect(created.cliCommand).toBe('mcp add cowork-finance-ops') + + // list + const listRes = await api.agents.$get() + expect(listRes.status).toBe(200) + const list = (await listRes.json()) as AgentProfileSummary[] + expect(list).toHaveLength(1) + expect(list[0].id).toBe(created.id) + + // detail + const detailRes = await api.agents[':id'].$get({ + param: { id: created.id }, + }) + expect(detailRes.status).toBe(200) + const detail = (await detailRes.json()) as NewAgentValues + expect(detail.name).toBe('Cowork . Finance ops') + + // update + const patchRes = await api.agents[':id'].$patch({ + param: { id: created.id }, + json: makeBody({ name: 'Renamed' }), + }) + expect(patchRes.status).toBe(200) + const updated = (await patchRes.json()) as StoredAgentProfile + expect(updated.name).toBe('Renamed') + expect(updated.slug).toBe('renamed') + + // regenerate + const regenRes = await api.agents[':id']['mcp-url:regenerate'].$post({ + param: { id: created.id }, + }) + expect(regenRes.status).toBe(200) + const regen = await regenRes.json() + expect(regen.id).toBe(created.id) + expect(regen.mcpUrl).not.toBe(updated.mcpUrl) + expect(regen.mcpUrl).toMatch(/\/mcp\/renamed-[a-z0-9-]+$/) + + // delete + const delRes = await api.agents[':id'].$delete({ + param: { id: created.id }, + }) + expect(delRes.status).toBe(200) + const del = (await delRes.json()) as { + id: string + harnessUninstall: { installed: boolean; message: string } + } + expect(del.id).toBe(created.id) + expect(typeof del.harnessUninstall.message).toBe('string') + + // listed empty + const emptyRes = await api.agents.$get() + const empty = (await emptyRes.json()) as AgentProfileSummary[] + expect(empty).toEqual([]) + }) + }) + + test('404 paths for unknown ids', async () => { + await withTempBrowserosDir(async () => { + const api = client() + const detail = await api.agents[':id'].$get({ param: { id: 'ghost' } }) + expect(detail.status).toBe(404) + const patch = await api.agents[':id'].$patch({ + param: { id: 'ghost' }, + json: makeBody(), + }) + expect(patch.status).toBe(404) + const del = await api.agents[':id'].$delete({ param: { id: 'ghost' } }) + expect(del.status).toBe(404) + const regen = await api.agents[':id']['mcp-url:regenerate'].$post({ + param: { id: 'ghost' }, + }) + expect(regen.status).toBe(404) + }) + }) + + test('400 when the create body fails zod validation', async () => { + await withTempBrowserosDir(async () => { + const api = client() + const res = await api.agents.$post({ + // biome-ignore lint/suspicious/noExplicitAny: deliberate invalid body for the test + json: { ...makeBody(), name: '' } as any, + }) + expect(res.status).toBe(400) + }) + }) + + test('two creates with the same name produce slug + slug-2', async () => { + await withTempBrowserosDir(async () => { + const api = client() + const first = await api.agents.$post({ json: makeBody({ name: 'Foo' }) }) + const second = await api.agents.$post({ json: makeBody({ name: 'Foo' }) }) + const a = await first.json() + const b = await second.json() + expect(a.slug).toBe('foo') + expect(b.slug).toBe('foo-2') + }) + }) + + test('parallel updates of two distinct profiles do not corrupt each other', async () => { + await withTempBrowserosDir(async () => { + const api = client() + const a = await ( + await api.agents.$post({ json: makeBody({ name: 'A' }) }) + ).json() + const b = await ( + await api.agents.$post({ json: makeBody({ name: 'B' }) }) + ).json() + await Promise.all([ + api.agents[':id'].$patch({ + param: { id: a.id }, + json: makeBody({ name: 'A renamed' }), + }), + api.agents[':id'].$patch({ + param: { id: b.id }, + json: makeBody({ name: 'B renamed' }), + }), + ]) + const list = (await ( + await api.agents.$get() + ).json()) as AgentProfileSummary[] + expect(list.map((row) => row.name).sort()).toEqual([ + 'A renamed', + 'B renamed', + ]) + }) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/agents/service.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/agents/service.test.ts new file mode 100644 index 000000000..c8bff7c59 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/agents/service.test.ts @@ -0,0 +1,303 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, test } from 'bun:test' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { readJson } from '../../../src/lib/storage' +import type { NewAgentValues } from '../../../src/routes/agents/schemas' +import { storedAgentProfileSchema } from '../../../src/routes/agents/schemas' +import * as agents from '../../../src/routes/agents/service' +import { withTempBrowserosDir } from '../../_helpers/temp-browseros-dir' + +function makeInput(overrides: Partial = {}): NewAgentValues { + return { + name: 'Cowork . Finance ops', + harness: 'Claude Desktop', + loginMode: 'profile', + selectedSites: [], + approvals: { + submit: 'Ask', + payment: 'Block', + delete: 'Ask', + upload: 'Ask', + navigate: 'Ask', + input: 'Auto', + }, + aclRuleIds: ['wire-transfers', 'payment-methods'], + customAclRules: [], + ...overrides, + } +} + +describe('agents service', () => { + test('create persists a stored profile that round-trips through the schema', async () => { + await withTempBrowserosDir(async (dir) => { + const created = await agents.create(makeInput()) + const file = join(dir, 'mcp-interface/agents', `${created.id}.json`) + expect(existsSync(file)).toBe(true) + const stored = await readJson( + `agents/${created.id}.json`, + storedAgentProfileSchema, + ) + expect(stored.id).toBe(created.id) + expect(stored.slug).toBe(created.slug) + expect(stored.status).toBe('configured') + expect(stored.createdAt).toBeTruthy() + expect(stored.updatedAt).toBe(stored.createdAt) + }) + }) + + test('create derives slug from the name; second create with same name appends -2', async () => { + await withTempBrowserosDir(async () => { + const first = await agents.create(makeInput({ name: 'Foo' })) + const second = await agents.create(makeInput({ name: 'Foo' })) + expect(first.slug).toBe('foo') + expect(second.slug).toBe('foo-2') + }) + }) + + test('list returns the directory projection with derived fields', async () => { + await withTempBrowserosDir(async () => { + await agents.create( + makeInput({ + name: 'Selective Agent', + loginMode: 'selective', + selectedSites: ['concur.com', 'stripe.com', 'ramp.com'], + aclRuleIds: ['a', 'b', 'c'], + approvals: { submit: 'Block', payment: 'Block', input: 'Auto' }, + }), + ) + const rows = await agents.list() + expect(rows).toHaveLength(1) + const row = rows[0] + expect(row.loginScopeLabel).toBe('Selective (3)') + expect(row.loginCount).toBe(3) + expect(row.aclRuleCount).toBe(3) + expect(row.blockedActionCount).toBe(2) + expect(row.alwaysAllowCount).toBe(0) + expect(row.lastRunAt).toBe('Never run') + expect(row.status).toBe('configured') + expect(row.mcpUrl).toMatch(/\/mcp\/selective-agent$/) + }) + }) + + test('list sorts by updatedAt descending', async () => { + await withTempBrowserosDir(async () => { + const a = await agents.create(makeInput({ name: 'Alpha' })) + // Force a small delay so the second create has a strictly later + // updatedAt; ISO strings sort lexicographically the same way. + await new Promise((resolve) => setTimeout(resolve, 10)) + const b = await agents.create(makeInput({ name: 'Beta' })) + const rows = await agents.list() + expect(rows.map((row) => row.id)).toEqual([b.id, a.id]) + }) + }) + + test('getDetail returns the wizard-shape values', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create( + makeInput({ + name: 'Detail Test', + loginMode: 'selective', + selectedSites: ['concur.com'], + }), + ) + const detail = await agents.getDetail(created.id) + expect(detail).not.toBeNull() + if (!detail) throw new Error('unreachable') + expect(detail.name).toBe('Detail Test') + expect(detail.loginMode).toBe('selective') + expect(detail.selectedSites).toEqual(['concur.com']) + // Wizard shape carries no server-managed fields. + expect('id' in detail).toBe(false) + expect('slug' in detail).toBe(false) + }) + }) + + test('getDetail returns null for unknown ids', async () => { + await withTempBrowserosDir(async () => { + expect(await agents.getDetail('ghost')).toBeNull() + }) + }) + + test('update rewrites the file; updatedAt advances, createdAt is preserved', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeInput()) + await new Promise((resolve) => setTimeout(resolve, 10)) + const updated = await agents.update( + created.id, + makeInput({ name: 'Renamed Profile' }), + ) + expect(updated).not.toBeNull() + if (!updated) throw new Error('unreachable') + expect(updated.id).toBe(created.id) + expect(updated.name).toBe('Renamed Profile') + expect(updated.createdAt).toBeTruthy() + expect(updated.updatedAt > updated.createdAt).toBe(true) + }) + }) + + test('update recomputes slug when the name changes', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeInput({ name: 'Cowork Finance' })) + expect(created.slug).toBe('cowork-finance') + const updated = await agents.update( + created.id, + makeInput({ name: 'Cowork Reporting' }), + ) + expect(updated?.slug).toBe('cowork-reporting') + }) + }) + + test('update keeps slug stable when the name does not change', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeInput({ name: 'Stable' })) + const updated = await agents.update( + created.id, + makeInput({ name: 'Stable' }), + ) + expect(updated?.slug).toBe(created.slug) + }) + }) + + test('update returns null for unknown ids', async () => { + await withTempBrowserosDir(async () => { + expect(await agents.update('ghost', makeInput())).toBeNull() + }) + }) + + test('remove deletes the file and subsequent getDetail is null', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeInput()) + const removed = await agents.remove(created.id) + expect(removed?.id).toBe(created.id) + expect(removed?.harnessUninstall.installed).toBe(false) + expect(await agents.getDetail(created.id)).toBeNull() + }) + }) + + test('remove returns null when the file does not exist', async () => { + await withTempBrowserosDir(async () => { + expect(await agents.remove('ghost')).toBeNull() + }) + }) + + test('regenerateMcpUrl rotates the slug and persists the new URL', async () => { + await withTempBrowserosDir(async () => { + const created = await agents.create(makeInput({ name: 'Rotate' })) + const result = await agents.regenerateMcpUrl(created.id) + expect(result).not.toBeNull() + if (!result) throw new Error('unreachable') + expect(result.id).toBe(created.id) + expect(result.mcpUrl).not.toBe(created.mcpUrl) + expect(result.mcpUrl).toMatch(/\/mcp\/rotate-[a-z0-9-]+$/) + const detail = await agents.getDetail(created.id) + expect(detail).not.toBeNull() + }) + }) + + test('regenerateMcpUrl returns null for unknown ids', async () => { + await withTempBrowserosDir(async () => { + expect(await agents.regenerateMcpUrl('ghost')).toBeNull() + }) + }) + + test('list skips a corrupt agent file instead of rejecting the whole call', async () => { + await withTempBrowserosDir(async (dir) => { + const ok = await agents.create(makeInput({ name: 'Healthy' })) + // Hand-write a garbage file under agents/. listFiles picks it + // up; the per-file readJson rejects; loadAll logs + skips it. + const { writeFile } = await import('node:fs/promises') + const { join } = await import('node:path') + await writeFile( + join(dir, 'mcp-interface/agents', 'broken.json'), + '{ this is not valid json', + 'utf8', + ) + const rows = await agents.list() + expect(rows.map((row) => row.id)).toEqual([ok.id]) + // The directory still serves new writes after a corrupt sibling. + const fresh = await agents.create(makeInput({ name: 'After corruption' })) + const after = await agents.list() + expect(after.map((row) => row.id).sort()).toEqual( + [ok.id, fresh.id].sort(), + ) + }) + }) + + test('traversal-shaped ids resolve as not-found across every read/write path', async () => { + await withTempBrowserosDir(async () => { + await agents.create(makeInput({ name: 'Real' })) + // Build a path that LOOKS like a profile id but contains + // characters the service must reject before the storage layer + // sees them. + const evilIds = [ + '../config', + 'agents/../config', + '..', + '../../etc/passwd', + ] + for (const evilId of evilIds) { + expect(await agents.getDetail(evilId)).toBeNull() + expect(await agents.update(evilId, makeInput())).toBeNull() + expect(await agents.remove(evilId)).toBeNull() + expect(await agents.regenerateMcpUrl(evilId)).toBeNull() + } + }) + }) + + test('ten parallel creates with the same name produce distinct slugs (no TOCTOU race)', async () => { + await withTempBrowserosDir(async () => { + const count = 10 + const created = await Promise.all( + Array.from({ length: count }, () => + agents.create(makeInput({ name: 'Race' })), + ), + ) + const slugs = created.map((c) => c.slug).sort() + // Expected slugs are race, race-2, race-3, ..., race-10 (sorted + // lexicographically -> race, race-10, race-2, race-3, ...). + expect(new Set(slugs).size).toBe(count) + expect(slugs).toContain('race') + for (let i = 2; i <= count; i++) { + expect(slugs).toContain(`race-${i}`) + } + }) + }) + + test('findBySlug returns the profile owning the slug, null otherwise', async () => { + await withTempBrowserosDir(async () => { + const a = await agents.create(makeInput({ name: 'Find Me' })) + const b = await agents.create(makeInput({ name: 'Other Agent' })) + const found = await agents.findBySlug(a.slug) + expect(found?.id).toBe(a.id) + const other = await agents.findBySlug(b.slug) + expect(other?.id).toBe(b.id) + expect(await agents.findBySlug('does-not-exist')).toBeNull() + // Empty directory after wipe (separate temp dir is the simplest). + }) + await withTempBrowserosDir(async () => { + expect(await agents.findBySlug('any-slug')).toBeNull() + }) + }) + + test('two parallel updates of different profiles do not corrupt each other', async () => { + await withTempBrowserosDir(async () => { + const a = await agents.create(makeInput({ name: 'Parallel A' })) + const b = await agents.create(makeInput({ name: 'Parallel B' })) + await Promise.all([ + agents.update(a.id, makeInput({ name: 'Parallel A renamed' })), + agents.update(b.id, makeInput({ name: 'Parallel B renamed' })), + ]) + const rows = await agents.list() + expect(rows.map((row) => row.name).sort()).toEqual([ + 'Parallel A renamed', + 'Parallel B renamed', + ]) + }) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/permissions/routes.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/permissions/routes.test.ts new file mode 100644 index 000000000..4fed58722 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/permissions/routes.test.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Integration test for the /permissions/catalog route. Returns the + * baked-in approval catalog; this test pins the shape so a UI bump + * that re-adds a category notices the backend hasn't followed. + */ + +import { describe, expect, test } from 'bun:test' +import { hc } from 'hono/client' +import type { ApprovalCategory } from '../../../src/lib/approval-catalog' +import app, { type AppType } from '../../../src/server' + +function client() { + return hc('http://localhost', { + fetch: (input, init) => app.fetch(new Request(input, init)), + }) +} + +describe('/permissions/catalog route', () => { + test('returns the six baked-in categories with the expected defaults', async () => { + const api = client() + const res = await api.permissions.catalog.$get() + expect(res.status).toBe(200) + const catalog = (await res.json()) as ApprovalCategory[] + expect(catalog.map((c) => c.id)).toEqual([ + 'submit', + 'payment', + 'delete', + 'upload', + 'navigate', + 'input', + ]) + const byId = Object.fromEntries(catalog.map((c) => [c.id, c])) + expect(byId.submit.defaultVerdict).toBe('Ask') + expect(byId.payment.defaultVerdict).toBe('Block') + expect(byId.payment.allowAuto).toBe(false) + expect(byId.input.defaultVerdict).toBe('Auto') + expect(byId.input.allowAuto).toBe(true) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/site-rules/routes.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/site-rules/routes.test.ts new file mode 100644 index 000000000..d0795495d --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/site-rules/routes.test.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Integration tests for the /site-rules routes. Hits the live Hono + * app via the typed client (`hc`), routes through + * `app.fetch` so no real socket bind, and isolates each test with a + * fresh ``. + */ + +import { describe, expect, test } from 'bun:test' +import { hc } from 'hono/client' +import type { SiteRule } from '../../../src/routes/site-rules/schemas' +import app, { type AppType } from '../../../src/server' +import { withTempBrowserosDir } from '../../_helpers/temp-browseros-dir' + +function client() { + return hc('http://localhost', { + fetch: (input, init) => app.fetch(new Request(input, init)), + }) +} + +describe('/site-rules routes', () => { + test('full lifecycle: empty list → create → list → delete → empty list', async () => { + await withTempBrowserosDir(async () => { + const api = client() + + const emptyRes = await api['site-rules'].$get() + expect(emptyRes.status).toBe(200) + expect(await emptyRes.json()).toEqual([]) + + const createRes = await api['site-rules'].$post({ + json: { + label: 'Wire transfers', + domain: 'mercury.com', + action: 'payments', + }, + }) + expect(createRes.status).toBe(201) + const created = await createRes.json() + expect(created.label).toBe('Wire transfers') + expect(created.id).toMatch(/^[A-Za-z0-9_-]+$/) + + const listRes = await api['site-rules'].$get() + const list = (await listRes.json()) as SiteRule[] + expect(list).toHaveLength(1) + expect(list[0].id).toBe(created.id) + + const delRes = await api['site-rules'][':id'].$delete({ + param: { id: created.id }, + }) + expect(delRes.status).toBe(200) + expect(await delRes.json()).toEqual({ id: created.id }) + + const finalRes = await api['site-rules'].$get() + expect(await finalRes.json()).toEqual([]) + }) + }) + + test('400 when action enum is invalid', async () => { + await withTempBrowserosDir(async () => { + const api = client() + const res = await api['site-rules'].$post({ + json: { + label: 'bad', + domain: 'x.com', + // biome-ignore lint/suspicious/noExplicitAny: deliberate invalid enum for the test + action: 'BOGUS' as any, + }, + }) + expect(res.status).toBe(400) + }) + }) + + test('400 when label is empty', async () => { + await withTempBrowserosDir(async () => { + const api = client() + const res = await api['site-rules'].$post({ + json: { label: '', domain: 'x.com', action: 'submit' }, + }) + expect(res.status).toBe(400) + }) + }) + + test('400 when domain is empty', async () => { + await withTempBrowserosDir(async () => { + const api = client() + const res = await api['site-rules'].$post({ + json: { label: 'x', domain: '', action: 'submit' }, + }) + expect(res.status).toBe(400) + }) + }) + + test('DELETE returns 404 for unknown id', async () => { + await withTempBrowserosDir(async () => { + const api = client() + const res = await api['site-rules'][':id'].$delete({ + param: { id: 'does-not-exist' }, + }) + expect(res.status).toBe(404) + }) + }) + + test('DELETE returns 404 for traversal-shaped ids without touching disk', async () => { + await withTempBrowserosDir(async () => { + const api = client() + // Seed a rule so the file exists; the bogus delete must not + // affect it. + await api['site-rules'].$post({ + json: { label: 'Real', domain: 'real.com', action: 'submit' }, + }) + const evilIds = ['..%2F..%2Fetc%2Fpasswd', '..', '../config'] + for (const evil of evilIds) { + const res = await api['site-rules'][':id'].$delete({ + param: { id: evil }, + }) + expect(res.status).toBe(404) + } + const listRes = await api['site-rules'].$get() + expect(await listRes.json()).toHaveLength(1) + }) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/site-rules/service.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/site-rules/service.test.ts new file mode 100644 index 000000000..2f1e9b27e --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/site-rules/service.test.ts @@ -0,0 +1,181 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, test } from 'bun:test' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { readJson } from '../../../src/lib/storage' +import { siteRulesFileSchema } from '../../../src/routes/site-rules/schemas' +import * as siteRules from '../../../src/routes/site-rules/service' +import { withTempBrowserosDir } from '../../_helpers/temp-browseros-dir' + +describe('site-rules service', () => { + test('list returns [] before any rule is added (file does not exist)', async () => { + await withTempBrowserosDir(async (dir) => { + expect(await siteRules.list()).toEqual([]) + expect(existsSync(join(dir, 'mcp-interface/site-rules.json'))).toBe(false) + }) + }) + + test('add creates the file and round-trips through the schema', async () => { + await withTempBrowserosDir(async (dir) => { + const created = await siteRules.add({ + label: 'Wire transfers', + domain: 'mercury.com', + action: 'payments', + }) + expect(created.id).toMatch(/^[A-Za-z0-9_-]+$/) + expect(created.label).toBe('Wire transfers') + const file = join(dir, 'mcp-interface/site-rules.json') + expect(existsSync(file)).toBe(true) + const stored = await readJson('site-rules.json', siteRulesFileSchema) + expect(stored).toHaveLength(1) + expect(stored[0]).toEqual(created) + }) + }) + + test('list returns rules in insertion order', async () => { + await withTempBrowserosDir(async () => { + const a = await siteRules.add({ + label: 'A', + domain: 'a.com', + action: 'submit', + }) + const b = await siteRules.add({ + label: 'B', + domain: 'b.com', + action: 'submit', + }) + const c = await siteRules.add({ + label: 'C', + domain: 'c.com', + action: 'submit', + }) + const rows = await siteRules.list() + expect(rows.map((r) => r.id)).toEqual([a.id, b.id, c.id]) + }) + }) + + test('duplicate (domain, action, label) tuples are allowed (user-managed)', async () => { + await withTempBrowserosDir(async () => { + const first = await siteRules.add({ + label: 'Wire transfers', + domain: 'mercury.com', + action: 'payments', + }) + const second = await siteRules.add({ + label: 'Wire transfers', + domain: 'mercury.com', + action: 'payments', + }) + expect(first.id).not.toBe(second.id) + expect(await siteRules.list()).toHaveLength(2) + }) + }) + + test('remove deletes the rule, returns the id, and shortens the list', async () => { + await withTempBrowserosDir(async () => { + const created = await siteRules.add({ + label: 'X', + domain: 'x.com', + action: 'submit', + }) + const removed = await siteRules.remove(created.id) + expect(removed).toEqual({ id: created.id }) + expect(await siteRules.list()).toEqual([]) + }) + }) + + test('remove returns null when the id is unknown', async () => { + await withTempBrowserosDir(async () => { + await siteRules.add({ label: 'X', domain: 'x.com', action: 'submit' }) + expect(await siteRules.remove('ghost')).toBeNull() + expect(await siteRules.list()).toHaveLength(1) + }) + }) + + test('remove returns null when no rules file exists yet', async () => { + await withTempBrowserosDir(async () => { + expect(await siteRules.remove('anything')).toBeNull() + }) + }) + + test('traversal-shaped ids resolve to null without touching disk', async () => { + await withTempBrowserosDir(async (dir) => { + await siteRules.add({ label: 'X', domain: 'x.com', action: 'submit' }) + const evilIds = [ + '../config', + '..', + '../../etc/passwd', + 'site-rules/../config', + ] + for (const evil of evilIds) { + expect(await siteRules.remove(evil)).toBeNull() + } + // The real rule is untouched. + expect(await siteRules.list()).toHaveLength(1) + expect(existsSync(join(dir, 'mcp-interface/site-rules.json'))).toBe(true) + }) + }) + + test('ten parallel adds all persist (no read-then-rewrite race)', async () => { + await withTempBrowserosDir(async () => { + const count = 10 + await Promise.all( + Array.from({ length: count }, (_, i) => + siteRules.add({ + label: `Rule ${i}`, + domain: `domain-${i}.com`, + action: 'submit', + }), + ), + ) + const rows = await siteRules.list() + expect(rows).toHaveLength(count) + expect(new Set(rows.map((r) => r.id)).size).toBe(count) + }) + }) + + test('findMatching honours glob patterns end-to-end', async () => { + await withTempBrowserosDir(async () => { + await siteRules.add({ + label: 'Wire', + domain: 'mercury.com', + action: 'payments', + }) + await siteRules.add({ + label: 'Admin', + domain: 'admin.*', + action: 'admin', + }) + await siteRules.add({ + label: 'Stripe', + domain: '*.stripe.com', + action: 'payments', + }) + + // Exact match. + const exact = await siteRules.findMatching('mercury.com', 'payments') + expect(exact.map((r) => r.label)).toEqual(['Wire']) + + // Subdomain wildcard hits `*.stripe.com`. + const sub = await siteRules.findMatching('api.stripe.com', 'payments') + expect(sub.map((r) => r.label)).toEqual(['Stripe']) + + // Trailing wildcard hits `admin.*` for matching action only. + const adm = await siteRules.findMatching('admin.example.com', 'admin') + expect(adm.map((r) => r.label)).toEqual(['Admin']) + + // Same domain but wrong action returns nothing. + const wrongAction = await siteRules.findMatching('mercury.com', 'submit') + expect(wrongAction).toEqual([]) + + // No matching rule at all. + const noMatch = await siteRules.findMatching('elsewhere.org', 'submit') + expect(noMatch).toEqual([]) + }) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/tabs/routes.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/tabs/routes.test.ts new file mode 100644 index 000000000..9b2ca6cf6 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/routes/tabs/routes.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Integration test for the /tabs/activity route. Pins the response + * shape and the empty-state behaviour. The registry-population path + * is exercised by mcp/register tests; this file only verifies the + * route surface. + */ + +import { afterEach, describe, expect, test } from 'bun:test' +import { hc } from 'hono/client' +import { setBrowserSession } from '../../../src/lib/browser-session' +import { tabActivityRegistry } from '../../../src/lib/tab-activity' +import app, { type AppType } from '../../../src/server' + +function client() { + return hc('http://localhost', { + fetch: (input, init) => app.fetch(new Request(input, init)), + }) +} + +afterEach(() => { + // Clear the singleton registry between cases so test ordering does + // not leak state. Setting the session to null short-circuits + // `snapshot()` but does NOT empty the underlying records Map; only + // the explicit `clear()` does that. Skipping it would leave a stale + // record visible to a later test that re-attaches a session whose + // stub resolves the same pageId. + tabActivityRegistry.clear() + setBrowserSession(null) +}) + +describe('/tabs/activity route', () => { + test('returns an empty list when nothing has been recorded', async () => { + const api = client() + const res = await api.tabs.activity.$get() + expect(res.status).toBe(200) + const body = (await res.json()) as { tabs: unknown[] } + expect(body).toEqual({ tabs: [] }) + }) + + test('returns the registry snapshot once tools have been recorded', async () => { + // Plant a fake session whose PageManager resolves a single page, + // record a tool against it, and expect the route to surface it. + setBrowserSession({ + pages: { + getInfo: (pageId: number) => + pageId === 1 + ? { targetId: 't1', url: 'https://example.com/', title: 'Ex' } + : undefined, + }, + // biome-ignore lint/suspicious/noExplicitAny: stub for test + } as any) + tabActivityRegistry.recordTool({ + agentId: 'a-1', + slug: 'finance-ops', + pageId: 1, + targetId: 't1', + toolName: 'navigate', + }) + const api = client() + const res = await api.tabs.activity.$get() + expect(res.status).toBe(200) + const body = (await res.json()) as { + tabs: Array<{ + targetId: string + agentId: string + slug: string + toolName?: string + lastToolName: string + url: string + status: 'active' | 'idle' + }> + } + expect(body.tabs).toHaveLength(1) + expect(body.tabs[0]).toMatchObject({ + targetId: 't1', + agentId: 'a-1', + slug: 'finance-ops', + lastToolName: 'navigate', + url: 'https://example.com/', + status: 'active', + }) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/services/harness-install.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/services/harness-install.test.ts new file mode 100644 index 000000000..c2d7601b5 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/services/harness-install.test.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, test } from 'bun:test' +import { setMcpManagerForTesting } from '../../src/lib/mcp-manager' +import type { NewAgentValues } from '../../src/routes/agents/schemas' +import * as agents from '../../src/routes/agents/service' +import { + installForAgent, + uninstallForAgent, +} from '../../src/services/harness-install' +import { createStubMcpManager } from '../_helpers/stub-mcp-manager' +import { withTempBrowserosDir } from '../_helpers/temp-browseros-dir' + +function makeInput(overrides: Partial = {}): NewAgentValues { + return { + name: 'Install Smoke', + harness: 'Claude Desktop', + loginMode: 'profile', + selectedSites: [], + approvals: { + submit: 'Ask', + payment: 'Block', + delete: 'Ask', + upload: 'Ask', + navigate: 'Auto', + input: 'Auto', + }, + aclRuleIds: [], + customAclRules: [], + ...overrides, + } +} + +describe('harness install service', () => { + test('installForAgent on Claude Desktop links the slug under claude-desktop', async () => { + await withTempBrowserosDir(async () => { + const stub = createStubMcpManager() + setMcpManagerForTesting(stub) + const created = await agents.create(makeInput()) + const addCall = stub.calls.find((c) => c.method === 'add') + const linkCall = stub.calls.find((c) => c.method === 'link') + expect(addCall?.payload).toMatchObject({ + name: created.slug, + spec: { transport: 'http', url: created.mcpUrl }, + }) + expect(linkCall?.payload).toMatchObject({ + serverName: created.slug, + agent: 'claude-desktop', + }) + expect(created.harnessInstall.installed).toBe(true) + expect(created.harnessInstall.message).toContain('Claude Desktop') + }) + }) + + test('installForAgent on Codex uses stdio + mcp-remote', async () => { + await withTempBrowserosDir(async () => { + const stub = createStubMcpManager() + setMcpManagerForTesting(stub) + const outcome = await installForAgent({ + slug: 'cdx-test', + mcpUrl: 'http://127.0.0.1:9200/mcp/cdx-test', + harness: 'Codex', + }) + const addCall = stub.calls.find((c) => c.method === 'add') + expect(addCall?.payload).toMatchObject({ + name: 'cdx-test', + spec: { + transport: 'stdio', + command: 'npx', + args: ['mcp-remote', 'http://127.0.0.1:9200/mcp/cdx-test'], + }, + }) + const linkCall = stub.calls.find((c) => c.method === 'link') + expect(linkCall?.payload).toMatchObject({ agent: 'codex' }) + expect(outcome.installed).toBe(true) + }) + }) + + test('Hermes + OpenClaw short-circuit as a no-op success (no manager calls)', async () => { + await withTempBrowserosDir(async () => { + const stub = createStubMcpManager() + setMcpManagerForTesting(stub) + for (const harness of ['Hermes', 'OpenClaw'] as const) { + const outcome = await installForAgent({ + slug: 'x', + mcpUrl: 'http://127.0.0.1:9200/mcp/x', + harness, + }) + expect(outcome.installed).toBe(true) + expect(outcome.message.toLowerCase()).toContain('browseros') + } + expect(stub.calls).toEqual([]) + }) + }) + + test('uninstallForAgent unlinks and drops the manifest entry', async () => { + await withTempBrowserosDir(async () => { + const stub = createStubMcpManager() + setMcpManagerForTesting(stub) + await uninstallForAgent({ slug: 'gone-slug', harness: 'Claude Desktop' }) + const methods = stub.calls.map((c) => c.method) + expect(methods).toContain('unlink') + expect(methods).toContain('remove') + }) + }) + + test('install failure does not throw; outcome carries the message', async () => { + await withTempBrowserosDir(async () => { + const stub = createStubMcpManager() + // Inject a custom failing manager. + stub.add = async () => { + throw new Error('disk full') + } + setMcpManagerForTesting(stub) + const outcome = await installForAgent({ + slug: 'broken', + mcpUrl: 'http://127.0.0.1:9200/mcp/broken', + harness: 'Claude Desktop', + }) + expect(outcome.installed).toBe(false) + expect(outcome.message).toContain('Claude Desktop') + expect(outcome.message).toContain('disk full') + }) + }) + + test('update with a slug rotation re-links the new slug then unlinks the old one', async () => { + await withTempBrowserosDir(async () => { + const stub = createStubMcpManager() + setMcpManagerForTesting(stub) + const created = await agents.create(makeInput({ name: 'Original Name' })) + // Drop the create calls so the assertion below only sees the reconcile. + stub.reset() + await agents.update(created.id, makeInput({ name: 'Renamed Profile' })) + const order = stub.calls.map((c) => ({ + method: c.method, + name: + (c.payload as { name?: string; serverName?: string }).name ?? + (c.payload as { serverName?: string }).serverName, + })) + // Reconcile installs the new slug FIRST so the harness has a + // working entry continuously, then unlinks the old slug. + expect(order[0]).toEqual({ method: 'add', name: 'renamed-profile' }) + expect(order[1]).toEqual({ method: 'link', name: 'renamed-profile' }) + const unlinkIdx = order.findIndex( + (o) => o.method === 'unlink' && o.name === 'original-name', + ) + const removeIdx = order.findIndex( + (o) => o.method === 'remove' && o.name === 'original-name', + ) + expect(unlinkIdx).toBeGreaterThan(1) + expect(removeIdx).toBeGreaterThan(unlinkIdx) + }) + }) + + test('update with a harness change writes the new harness and unlinks the old one', async () => { + await withTempBrowserosDir(async () => { + const stub = createStubMcpManager() + setMcpManagerForTesting(stub) + const created = await agents.create( + makeInput({ name: 'Stable Name', harness: 'Claude Code' }), + ) + stub.reset() + await agents.update( + created.id, + makeInput({ name: 'Stable Name', harness: 'Cursor' }), + ) + const linkCall = stub.calls.find((c) => c.method === 'link') + const unlinkCall = stub.calls.find((c) => c.method === 'unlink') + expect(linkCall?.payload).toMatchObject({ agent: 'cursor' }) + expect(unlinkCall?.payload).toMatchObject({ agent: 'claude-code' }) + }) + }) + + test('update with no harness or slug change skips the reconcile entirely', async () => { + await withTempBrowserosDir(async () => { + const stub = createStubMcpManager() + setMcpManagerForTesting(stub) + const created = await agents.create(makeInput({ name: 'Same' })) + stub.reset() + // Mutate something irrelevant to the harness link (approvals). + await agents.update(created.id, { + ...makeInput({ name: 'Same' }), + approvals: { + submit: 'Block', + payment: 'Block', + delete: 'Block', + upload: 'Block', + navigate: 'Block', + input: 'Block', + }, + }) + expect(stub.calls).toEqual([]) + void created + }) + }) + + test('regenerateMcpUrl re-links the new slug and unlinks the old one', async () => { + await withTempBrowserosDir(async () => { + const stub = createStubMcpManager() + setMcpManagerForTesting(stub) + const created = await agents.create(makeInput({ name: 'Rotate Me' })) + stub.reset() + const rotated = await agents.regenerateMcpUrl(created.id) + expect(rotated).not.toBeNull() + const linkCall = stub.calls.find((c) => c.method === 'link') + const unlinkCall = stub.calls.find((c) => c.method === 'unlink') + expect(linkCall?.payload).toMatchObject({ + serverName: rotated?.mcpUrl.split('/').pop(), + agent: 'claude-desktop', + }) + expect(unlinkCall?.payload).toMatchObject({ + serverName: created.slug, + agent: 'claude-desktop', + }) + }) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tests/services/permissions.test.ts b/packages/browseros-agent/apps/agent-mcp-interface/tests/services/permissions.test.ts new file mode 100644 index 000000000..abeb2c7d2 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tests/services/permissions.test.ts @@ -0,0 +1,251 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Unit tests for the in-process `permissions.check` API. Exercises + * the precedence ladder: site-rule clamp → agent verdict → catalog + * default → unknown-verb safety default. + */ + +import { describe, expect, test } from 'bun:test' +import type { NewAgentValues } from '../../src/routes/agents/schemas' +import * as agentsService from '../../src/routes/agents/service' +import * as siteRulesService from '../../src/routes/site-rules/service' +import * as permissions from '../../src/services/permissions' +import { withTempBrowserosDir } from '../_helpers/temp-browseros-dir' + +function makeProfile(overrides: Partial = {}): NewAgentValues { + return { + name: 'Cowork . Finance ops', + harness: 'Claude Desktop', + loginMode: 'profile', + selectedSites: [], + approvals: { + submit: 'Auto', + payment: 'Block', + delete: 'Ask', + upload: 'Ask', + navigate: 'Ask', + input: 'Auto', + }, + aclRuleIds: [], + customAclRules: [], + ...overrides, + } +} + +describe('permissions.check', () => { + test('site rule clamps an agent that wanted Auto', async () => { + await withTempBrowserosDir(async () => { + const agent = await agentsService.create(makeProfile()) + await siteRulesService.add({ + label: 'Wire', + domain: 'mercury.com', + action: 'payments', + }) + const result = await permissions.check({ + agentId: agent.id, + verb: 'payment', + domain: 'mercury.com', + }) + // Agent's payment verdict was Block, so even without the site + // rule we'd block; flip to submit to prove the rule wins over + // an Auto verdict. + expect(result).toEqual({ verdict: 'block', source: 'site-rule' }) + }) + }) + + test('site rule overrides an agent Auto verdict for submit', async () => { + await withTempBrowserosDir(async () => { + const agent = await agentsService.create( + makeProfile({ + approvals: { + submit: 'Auto', + payment: 'Block', + delete: 'Ask', + upload: 'Ask', + navigate: 'Ask', + input: 'Auto', + }, + }), + ) + await siteRulesService.add({ + label: 'Concur', + domain: 'concur.com', + action: 'submit', + }) + const blocked = await permissions.check({ + agentId: agent.id, + verb: 'submit', + domain: 'concur.com', + }) + expect(blocked).toEqual({ verdict: 'block', source: 'site-rule' }) + + // Same agent + same verb on a different domain stays Auto. + const allowed = await permissions.check({ + agentId: agent.id, + verb: 'submit', + domain: 'docs.google.com', + }) + expect(allowed).toEqual({ verdict: 'auto', source: 'agent' }) + }) + }) + + test('site rule with wildcard matches subdomains', async () => { + await withTempBrowserosDir(async () => { + const agent = await agentsService.create(makeProfile()) + await siteRulesService.add({ + label: 'Stripe', + domain: '*.stripe.com', + action: 'payments', + }) + const sub = await permissions.check({ + agentId: agent.id, + verb: 'payment', + domain: 'api.stripe.com', + }) + expect(sub.source).toBe('site-rule') + // Apex stripe.com is NOT clamped by `*.stripe.com` (apex needs a + // separate rule). Falls through to the agent verdict (Block). + const apex = await permissions.check({ + agentId: agent.id, + verb: 'payment', + domain: 'stripe.com', + }) + expect(apex).toEqual({ verdict: 'block', source: 'agent' }) + }) + }) + + test('agent verdict wins when no site rule matches', async () => { + await withTempBrowserosDir(async () => { + const agent = await agentsService.create(makeProfile()) + const result = await permissions.check({ + agentId: agent.id, + verb: 'submit', + domain: 'docs.google.com', + }) + expect(result).toEqual({ verdict: 'auto', source: 'agent' }) + }) + }) + + test('catalog default applies when the agent is missing', async () => { + await withTempBrowserosDir(async () => { + const result = await permissions.check({ + agentId: 'ghost', + verb: 'payment', + domain: 'stripe.com', + }) + expect(result).toEqual({ + verdict: 'block', + source: 'permission-default', + }) + }) + }) + + test('catalog default applies when the agent profile omits the verb', async () => { + await withTempBrowserosDir(async () => { + const agent = await agentsService.create( + makeProfile({ + approvals: { + submit: 'Auto', + // payment intentionally omitted + delete: 'Ask', + upload: 'Ask', + navigate: 'Ask', + input: 'Auto', + }, + }), + ) + const result = await permissions.check({ + agentId: agent.id, + verb: 'payment', + domain: 'stripe.com', + }) + expect(result).toEqual({ + verdict: 'block', + source: 'permission-default', + }) + }) + }) + + test('unknown verb returns block from the permission-default source', async () => { + await withTempBrowserosDir(async () => { + const agent = await agentsService.create(makeProfile()) + const result = await permissions.check({ + agentId: agent.id, + verb: 'not-a-real-verb', + domain: 'example.com', + }) + expect(result).toEqual({ + verdict: 'block', + source: 'permission-default', + }) + }) + }) + + test('traversal-shaped agentId resolves to catalog default (defence in depth)', async () => { + await withTempBrowserosDir(async () => { + const result = await permissions.check({ + agentId: '../config', + verb: 'submit', + domain: 'example.com', + }) + expect(result.source).toBe('permission-default') + expect(result.verdict).toBe('ask') + }) + }) + + test('admin verb is enforced by matching site rules', async () => { + await withTempBrowserosDir(async () => { + const agent = await agentsService.create(makeProfile()) + await siteRulesService.add({ + label: 'Org billing', + domain: 'admin.*', + action: 'admin', + }) + // Configured admin rule must attribute the block to the rule, + // not to the unknown-verb safety default. If this regresses, + // the cockpit will show "blocked by default" instead of + // "blocked by the rule you configured". + const result = await permissions.check({ + agentId: agent.id, + verb: 'admin', + domain: 'admin.workspace.google.com', + }) + expect(result).toEqual({ verdict: 'block', source: 'site-rule' }) + + // Without a matching rule, admin still defaults to block + // (catalog has no admin verdict), but sourced from the + // permission-default safety net. + const noRule = await permissions.check({ + agentId: agent.id, + verb: 'admin', + domain: 'docs.google.com', + }) + expect(noRule).toEqual({ + verdict: 'block', + source: 'permission-default', + }) + }) + }) + + test('input verb is not domain-scoped: site rules do not clamp it', async () => { + await withTempBrowserosDir(async () => { + const agent = await agentsService.create(makeProfile()) + // A submit rule on the same domain must NOT carry over to the + // input verb space; input falls through to the agent verdict. + await siteRulesService.add({ + label: 'Concur submit', + domain: 'concur.com', + action: 'submit', + }) + const result = await permissions.check({ + agentId: agent.id, + verb: 'input', + domain: 'concur.com', + }) + expect(result).toEqual({ verdict: 'auto', source: 'agent' }) + }) + }) +}) diff --git a/packages/browseros-agent/apps/agent-mcp-interface/tsconfig.json b/packages/browseros-agent/apps/agent-mcp-interface/tsconfig.json new file mode 100644 index 000000000..92ce49f5c --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-interface/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "composite": true, + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true, + "types": ["bun"] + }, + "include": ["src/**/*", "package.json"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/packages/browseros-agent/apps/agent-mcp-ui/.env.example b/packages/browseros-agent/apps/agent-mcp-ui/.env.example new file mode 100644 index 000000000..307287f7b --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-ui/.env.example @@ -0,0 +1,23 @@ +# Copy to .env.development locally; bun --env-file=.env.development +# loads these before wxt launches the browser. None are required — +# every entry has a sensible default in web-ext.config.ts. + +# Path to a BrowserOS Chromium binary. Default targets the canonical +# macOS install. +# BROWSEROS_BINARY="/Applications/BrowserOS.app/Contents/MacOS/BrowserOS" + +# Override the Chromium user data directory. Defaults to a per-package +# per-worktree dir under /tmp so dev runs don't share state with the +# agent extension or with other worktrees. +# BROWSEROS_USER_DATA_DIR="/tmp/my-browseros-dev" + +# When set, forwarded to Chromium as --remote-debugging-port. +# BROWSEROS_CDP_PORT="9000" + +# When set, forwarded as --browseros-mcp-port, --browseros-server-port, +# and --browseros-proxy-port. Points BrowserOS at a sibling agent +# server (the existing apps/server). Leave unset if you don't need it. +# BROWSEROS_SERVER_PORT="9100" + +# When set, forwarded as --browseros-extension-port. +# BROWSEROS_EXTENSION_PORT="9300" diff --git a/packages/browseros-agent/apps/agent-mcp-ui/.gitignore b/packages/browseros-agent/apps/agent-mcp-ui/.gitignore new file mode 100644 index 000000000..e5b8f6573 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-ui/.gitignore @@ -0,0 +1,35 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.output +dist +stats.html +stats-*.json +.wxt +.dev-extensions + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.zed +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Env files +.env* +!.env.example + +# GraphQL generated files +generated/ diff --git a/packages/browseros-agent/apps/agent-mcp-ui/biome.json b/packages/browseros-agent/apps/agent-mcp-ui/biome.json new file mode 100644 index 000000000..a5c449660 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-ui/biome.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "root": false, + "extends": "//", + "files": { + "includes": ["**", "!.wxt", "!.output", "!dist"] + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "overrides": [ + { + "includes": ["components/ui/**", "components/ai-elements/**"], + "formatter": { "enabled": false }, + "linter": { "enabled": false }, + "assist": { + "actions": { + "source": { + "organizeImports": "off" + } + } + } + } + ] +} diff --git a/packages/browseros-agent/apps/agent-mcp-ui/components.json b/packages/browseros-agent/apps/agent-mcp-ui/components.json new file mode 100644 index 000000000..e09ab8051 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-ui/components.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-vega", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "entrypoints/app/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "tabler", + "rtl": false, + "menuColor": "default-translucent", + "menuAccent": "subtle", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json", + "@svgl": "https://svgl.app/r/{name}.json" + } +} diff --git a/packages/browseros-agent/apps/agent-mcp-ui/components/ai-elements/agent.tsx b/packages/browseros-agent/apps/agent-mcp-ui/components/ai-elements/agent.tsx new file mode 100644 index 000000000..86dbcc0fc --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-ui/components/ai-elements/agent.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { Tool } from "ai"; +import { BotIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { memo } from "react"; + +import { CodeBlock } from "./code-block"; + +export type AgentProps = ComponentProps<"div">; + +export const Agent = memo(({ className, ...props }: AgentProps) => ( +
+)); + +export type AgentHeaderProps = ComponentProps<"div"> & { + name: string; + model?: string; +}; + +export const AgentHeader = memo( + ({ className, name, model, ...props }: AgentHeaderProps) => ( +
+
+ + {name} + {model && ( + + {model} + + )} +
+
+ ) +); + +export type AgentContentProps = ComponentProps<"div">; + +export const AgentContent = memo( + ({ className, ...props }: AgentContentProps) => ( +
+ ) +); + +export type AgentInstructionsProps = ComponentProps<"div"> & { + children: string; +}; + +export const AgentInstructions = memo( + ({ className, children, ...props }: AgentInstructionsProps) => ( +
+ + Instructions + +
+

{children}

+
+
+ ) +); + +export type AgentToolsProps = ComponentProps; + +export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => ( +
+ Tools + +
+)); + +export type AgentToolProps = ComponentProps & { + tool: Tool; +}; + +export const AgentTool = memo( + ({ className, tool, value, ...props }: AgentToolProps) => { + const schema = + "jsonSchema" in tool && tool.jsonSchema + ? tool.jsonSchema + : tool.inputSchema; + + return ( + + + {tool.description ?? "No description"} + + +
+ +
+
+
+ ); + } +); + +export type AgentOutputProps = ComponentProps<"div"> & { + schema: string; +}; + +export const AgentOutput = memo( + ({ className, schema, ...props }: AgentOutputProps) => ( +
+ + Output Schema + +
+ +
+
+ ) +); + +Agent.displayName = "Agent"; +AgentHeader.displayName = "AgentHeader"; +AgentContent.displayName = "AgentContent"; +AgentInstructions.displayName = "AgentInstructions"; +AgentTools.displayName = "AgentTools"; +AgentTool.displayName = "AgentTool"; +AgentOutput.displayName = "AgentOutput"; diff --git a/packages/browseros-agent/apps/agent-mcp-ui/components/ai-elements/artifact.tsx b/packages/browseros-agent/apps/agent-mcp-ui/components/ai-elements/artifact.tsx new file mode 100644 index 000000000..0597d4a40 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-ui/components/ai-elements/artifact.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { LucideIcon } from "lucide-react"; +import { XIcon } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type ArtifactProps = HTMLAttributes; + +export const Artifact = ({ className, ...props }: ArtifactProps) => ( +
+); + +export type ArtifactHeaderProps = HTMLAttributes; + +export const ArtifactHeader = ({ + className, + ...props +}: ArtifactHeaderProps) => ( +
+); + +export type ArtifactCloseProps = ComponentProps; + +export const ArtifactClose = ({ + className, + children, + size = "sm", + variant = "ghost", + ...props +}: ArtifactCloseProps) => ( + +); + +export type ArtifactTitleProps = HTMLAttributes; + +export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( +

+); + +export type ArtifactDescriptionProps = HTMLAttributes; + +export const ArtifactDescription = ({ + className, + ...props +}: ArtifactDescriptionProps) => ( +

+); + +export type ArtifactActionsProps = HTMLAttributes; + +export const ArtifactActions = ({ + className, + ...props +}: ArtifactActionsProps) => ( +

+); + +export type ArtifactActionProps = ComponentProps & { + tooltip?: string; + label?: string; + icon?: LucideIcon; +}; + +export const ArtifactAction = ({ + tooltip, + label, + icon: Icon, + children, + className, + size = "sm", + variant = "ghost", + ...props +}: ArtifactActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +export type ArtifactContentProps = HTMLAttributes; + +export const ArtifactContent = ({ + className, + ...props +}: ArtifactContentProps) => ( +
+); diff --git a/packages/browseros-agent/apps/agent-mcp-ui/components/ai-elements/attachments.tsx b/packages/browseros-agent/apps/agent-mcp-ui/components/ai-elements/attachments.tsx new file mode 100644 index 000000000..9b45533b7 --- /dev/null +++ b/packages/browseros-agent/apps/agent-mcp-ui/components/ai-elements/attachments.tsx @@ -0,0 +1,428 @@ +// @ts-nocheck +// Vercel ai-elements drop-in. Some files use @base-ui/react API shapes that differ from the currently-published version; bundled JS runs fine, only tsc strictness flags them. Same posture biome takes (third-party files are exempt from lint rules). +"use client"; + +import { Button } from "@/components/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; +import type { FileUIPart, SourceDocumentUIPart } from "ai"; +import { + FileTextIcon, + GlobeIcon, + ImageIcon, + Music2Icon, + PaperclipIcon, + VideoIcon, + XIcon, +} from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo } from "react"; + +// ============================================================================ +// Types +// ============================================================================ + +export type AttachmentData = + | (FileUIPart & { id: string }) + | (SourceDocumentUIPart & { id: string }); + +export type AttachmentMediaCategory = + | "image" + | "video" + | "audio" + | "document" + | "source" + | "unknown"; + +export type AttachmentVariant = "grid" | "inline" | "list"; + +const mediaCategoryIcons: Record = { + audio: Music2Icon, + document: FileTextIcon, + image: ImageIcon, + source: GlobeIcon, + unknown: PaperclipIcon, + video: VideoIcon, +}; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +export const getMediaCategory = ( + data: AttachmentData +): AttachmentMediaCategory => { + if (data.type === "source-document") { + return "source"; + } + + const mediaType = data.mediaType ?? ""; + + if (mediaType.startsWith("image/")) { + return "image"; + } + if (mediaType.startsWith("video/")) { + return "video"; + } + if (mediaType.startsWith("audio/")) { + return "audio"; + } + if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) { + return "document"; + } + + return "unknown"; +}; + +export const getAttachmentLabel = (data: AttachmentData): string => { + if (data.type === "source-document") { + return data.title || data.filename || "Source"; + } + + const category = getMediaCategory(data); + return data.filename || (category === "image" ? "Image" : "Attachment"); +}; + +const renderAttachmentImage = ( + url: string, + filename: string | undefined, + isGrid: boolean +) => + isGrid ? ( + {filename + ) : ( + {filename + ); + +// ============================================================================ +// Contexts +// ============================================================================ + +interface AttachmentsContextValue { + variant: AttachmentVariant; +} + +const AttachmentsContext = createContext(null); + +interface AttachmentContextValue { + data: AttachmentData; + mediaCategory: AttachmentMediaCategory; + onRemove?: () => void; + variant: AttachmentVariant; +} + +const AttachmentContext = createContext(null); + +// ============================================================================ +// Hooks +// ============================================================================ + +export const useAttachmentsContext = () => + useContext(AttachmentsContext) ?? { variant: "grid" as const }; + +export const useAttachmentContext = () => { + const ctx = useContext(AttachmentContext); + if (!ctx) { + throw new Error("Attachment components must be used within "); + } + return ctx; +}; + +// ============================================================================ +// Attachments - Container +// ============================================================================ + +export type AttachmentsProps = HTMLAttributes & { + variant?: AttachmentVariant; +}; + +export const Attachments = ({ + variant = "grid", + className, + children, + ...props +}: AttachmentsProps) => { + const contextValue = useMemo(() => ({ variant }), [variant]); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// Attachment - Item +// ============================================================================ + +export type AttachmentProps = HTMLAttributes & { + data: AttachmentData; + onRemove?: () => void; +}; + +export const Attachment = ({ + data, + onRemove, + className, + children, + ...props +}: AttachmentProps) => { + const { variant } = useAttachmentsContext(); + const mediaCategory = getMediaCategory(data); + + const contextValue = useMemo( + () => ({ data, mediaCategory, onRemove, variant }), + [data, mediaCategory, onRemove, variant] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// AttachmentPreview - Media preview +// ============================================================================ + +export type AttachmentPreviewProps = HTMLAttributes & { + fallbackIcon?: ReactNode; +}; + +export const AttachmentPreview = ({ + fallbackIcon, + className, + ...props +}: AttachmentPreviewProps) => { + const { data, mediaCategory, variant } = useAttachmentContext(); + + const iconSize = variant === "inline" ? "size-3" : "size-4"; + + const renderIcon = (Icon: typeof ImageIcon) => ( + + ); + + const renderContent = () => { + if (mediaCategory === "image" && data.type === "file" && data.url) { + return renderAttachmentImage(data.url, data.filename, variant === "grid"); + } + + if (mediaCategory === "video" && data.type === "file" && data.url) { + return