diff --git a/plugins/fusion-plugin-agent-browser/README.md b/plugins/fusion-plugin-agent-browser/README.md new file mode 100644 index 000000000..ff16499c0 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/README.md @@ -0,0 +1,26 @@ +# fusion-plugin-agent-browser + +First-party Fusion plugin that contributes: + +- setup metadata/hooks for the `agent-browser` binary +- prompt contributions across executor/triage/reviewer/heartbeat surfaces +- agent-browser skills and workflow step templates +- optional browser helper tools + +## Settings + +- `enabled` (boolean, default `true`) +- `installChannel` (`stable|beta|nightly`, default `stable`) +- `commandTimeoutMs` (number, default `120000`) +- `headlessMode` (boolean, default `true`) +- `allowedDomains` (string array, default `[]`) +- `promptExecutorSystem` (string, default `"When browsing, summarize evidence with URLs."`) +- `promptExecutorTask` (string, default `"Use browser context only when needed for the task."`) +- `promptTriage` (string, default `"Mark tasks requiring browser evidence explicitly."`) +- `promptReviewer` (string, default `"Verify browser-derived claims are backed by cited pages."`) +- `promptHeartbeat` (string, default `"Keep browser interactions bounded and report failures clearly."`) +- `skillExposure` (`none|selected|all`, default `selected`) + +## Setup hooks + +`checkSetup()` probes `agent-browser --version` asynchronously with timeout handling and reports `installed`, `not-installed`, or `error` status via `PluginSetupCheckResult`. diff --git a/plugins/fusion-plugin-agent-browser/manifest.json b/plugins/fusion-plugin-agent-browser/manifest.json new file mode 100644 index 000000000..f16c37e7d --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "fusion-plugin-agent-browser", + "name": "Agent Browser Plugin", + "version": "0.1.0", + "description": "Adds agent-browser setup hooks plus skills, prompt contributions, and workflow steps", + "author": "Fusion Team", + "homepage": "https://github.com/gsxdsm/fusion", + "promptSurfaces": ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"], + "setup": { + "binaryName": "agent-browser", + "description": "Headless browser runtime for web-enabled agents", + "channel": "stable", + "defaultTimeoutMs": 120000 + } +} diff --git a/plugins/fusion-plugin-agent-browser/package.json b/plugins/fusion-plugin-agent-browser/package.json new file mode 100644 index 000000000..ba64de5c8 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/package.json @@ -0,0 +1,25 @@ +{ + "name": "@fusion-plugin-examples/agent-browser", + "version": "0.1.0", + "type": "module", + "description": "Agent Browser runtime and prompt/skill/workflow contributions for Fusion", + "private": true, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "test": "vitest run --silent=passed-only --reporter=dot" + }, + "dependencies": { + "@fusion/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.5.2", + "typescript": "^5.7.0", + "vitest": "^3.2.4" + } +} diff --git a/plugins/fusion-plugin-agent-browser/src/__tests__/index.test.ts b/plugins/fusion-plugin-agent-browser/src/__tests__/index.test.ts new file mode 100644 index 000000000..b30e0a621 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/__tests__/index.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import plugin, { AGENT_BROWSER_SETTINGS_SCHEMA } from "../index.js"; +import manifestJson from "../../manifest.json"; + +const { probeMock } = vi.hoisted(() => ({ + probeMock: vi.fn(async () => ({ available: false, reason: "`agent-browser` not found on PATH" } as { available: boolean; reason?: string; version?: string })), +})); + +vi.mock("../probe.js", () => ({ + probeAgentBrowserBinary: probeMock, +})); + +describe("agent-browser plugin index", () => { + it("exports canonical plugin id and manifest metadata", () => { + expect(plugin.manifest.id).toBe("fusion-plugin-agent-browser"); + expect(manifestJson.id).toBe("fusion-plugin-agent-browser"); + expect(plugin.manifest.setup?.binaryName).toBe("agent-browser"); + }); + + it("emits load event on successful probe", async () => { + probeMock.mockResolvedValueOnce({ available: true, version: "1.0.0" }); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const emitEvent = vi.fn(); + await plugin.hooks.onLoad?.({ + pluginId: plugin.manifest.id, + taskStore: {} as never, + settings: { promptExecutorSystem: "Use browser evidence" }, + logger, + emitEvent, + }); + expect(emitEvent).toHaveBeenCalledWith("agent-browser:loaded", expect.objectContaining({ available: true, version: "1.0.0" })); + expect(plugin.promptContributions?.contributions.some((p) => p.content.includes("Use browser evidence"))).toBe(true); + }); + + it("emits unavailable payload when probe throws", async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const emitEvent = vi.fn(); + probeMock.mockRejectedValueOnce(new Error("boom")); + await plugin.hooks.onLoad?.({ + pluginId: plugin.manifest.id, + taskStore: {} as never, + settings: {}, + logger, + emitEvent, + }); + expect(logger.error).toHaveBeenCalled(); + expect(emitEvent).toHaveBeenCalledWith("agent-browser:loaded", expect.objectContaining({ available: false, error: "boom" })); + }); + + it("has canonical settings keys", () => { + expect(Object.keys(AGENT_BROWSER_SETTINGS_SCHEMA)).toEqual([ + "enabled", + "installChannel", + "commandTimeoutMs", + "headlessMode", + "allowedDomains", + "promptExecutorSystem", + "promptExecutorTask", + "promptTriage", + "promptReviewer", + "promptHeartbeat", + "skillExposure", + ]); + expect(AGENT_BROWSER_SETTINGS_SCHEMA.installChannel?.defaultValue).toBe("stable"); + }); +}); diff --git a/plugins/fusion-plugin-agent-browser/src/__tests__/prompts.test.ts b/plugins/fusion-plugin-agent-browser/src/__tests__/prompts.test.ts new file mode 100644 index 000000000..a5fce4feb --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/__tests__/prompts.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { buildPromptContributions } from "../prompts.js"; + +describe("prompt contributions", () => { + it("covers all five prompt surfaces", () => { + const prompts = buildPromptContributions({ + promptExecutorSystem: "a", + promptExecutorTask: "b", + promptTriage: "c", + promptReviewer: "d", + promptHeartbeat: "e", + }); + expect(prompts.contributions.map((p) => p.surface)).toEqual([ + "executor-system", + "executor-task", + "triage", + "reviewer", + "heartbeat", + ]); + }); +}); diff --git a/plugins/fusion-plugin-agent-browser/src/__tests__/setup.test.ts b/plugins/fusion-plugin-agent-browser/src/__tests__/setup.test.ts new file mode 100644 index 000000000..b252f7103 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/__tests__/setup.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { checkSetup } from "../setup.js"; +import type { PluginContext } from "@fusion/plugin-sdk"; +import { probeAgentBrowserBinary } from "../probe.js"; + +vi.mock("../probe.js", () => ({ + probeAgentBrowserBinary: vi.fn(), +})); + +const probeMock = vi.mocked(probeAgentBrowserBinary); + +const ctx: PluginContext = { + pluginId: "fusion-plugin-agent-browser", + taskStore: {} as PluginContext["taskStore"], + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: () => undefined, +}; + +describe("setup hooks", () => { + it("returns not-installed when binary is missing", async () => { + probeMock.mockResolvedValueOnce({ available: false, reason: "`agent-browser` not found on PATH", notFound: true }); + const result = await checkSetup(ctx); + expect(result.status).toBe("not-installed"); + }); + + it("returns installed when probe succeeds", async () => { + probeMock.mockResolvedValueOnce({ available: true, version: "1.2.3", binaryPath: "/usr/bin/agent-browser" }); + const result = await checkSetup(ctx); + expect(result.status).toBe("installed"); + expect(result.version).toBe("1.2.3"); + }); + + it("returns error for non-not-found probe failures", async () => { + probeMock.mockResolvedValueOnce({ available: false, reason: "Probe timed out after 1000ms" }); + const result = await checkSetup(ctx); + expect(result.status).toBe("error"); + }); +}); diff --git a/plugins/fusion-plugin-agent-browser/src/__tests__/skills.test.ts b/plugins/fusion-plugin-agent-browser/src/__tests__/skills.test.ts new file mode 100644 index 000000000..27d4a5e44 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/__tests__/skills.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { AGENT_BROWSER_SKILLS } from "../skills.js"; + +describe("skills", () => { + it("declares skill metadata", () => { + expect(AGENT_BROWSER_SKILLS[0]?.skillId).toBe("agent-browser-navigation"); + expect(AGENT_BROWSER_SKILLS[0]?.skillFiles.length).toBeGreaterThan(0); + }); +}); diff --git a/plugins/fusion-plugin-agent-browser/src/__tests__/tools.test.ts b/plugins/fusion-plugin-agent-browser/src/__tests__/tools.test.ts new file mode 100644 index 000000000..d232f4223 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/__tests__/tools.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { AGENT_BROWSER_TOOLS } from "../tools.js"; +import type { PluginContext } from "@fusion/plugin-sdk"; + +const createContext = (settings: Record): PluginContext => ({ + pluginId: "fusion-plugin-agent-browser", + taskStore: {} as PluginContext["taskStore"], + settings, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), +}); + +describe("tools", () => { + it("blocks disallowed domains", async () => { + const tool = AGENT_BROWSER_TOOLS[0]; + const result = await tool.execute( + { url: "https://forbidden.example.com/path" }, + createContext({ allowedDomains: ["allowed.example.com"] }), + ); + expect(result.isError).toBe(true); + }); + + it("allows when allowlist is empty", async () => { + const tool = AGENT_BROWSER_TOOLS[0]; + const result = await tool.execute( + { url: "https://any.example.com/path" }, + createContext({ allowedDomains: [], headlessMode: true }), + ); + expect(result.isError).not.toBe(true); + }); + + it("allows matching domains", async () => { + const tool = AGENT_BROWSER_TOOLS[0]; + const result = await tool.execute( + { url: "https://docs.allowed.example.com/path" }, + createContext({ allowedDomains: ["allowed.example.com"], headlessMode: true }), + ); + expect(result.isError).not.toBe(true); + }); + + it("returns error for invalid URL", async () => { + const tool = AGENT_BROWSER_TOOLS[0]; + const result = await tool.execute({ url: "not a url" }, createContext({ allowedDomains: [] })); + expect(result.isError).toBe(true); + }); + + it("blocks sibling domains that only share suffix text", async () => { + const tool = AGENT_BROWSER_TOOLS[0]; + const result = await tool.execute( + { url: "https://notexample.com/path" }, + createContext({ allowedDomains: ["example.com"] }), + ); + expect(result.isError).toBe(true); + }); +}); diff --git a/plugins/fusion-plugin-agent-browser/src/__tests__/types.test.ts b/plugins/fusion-plugin-agent-browser/src/__tests__/types.test.ts new file mode 100644 index 000000000..edb98f417 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/__tests__/types.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_SETTINGS, resolveSettings } from "../types.js"; + +describe("resolveSettings", () => { + it("returns defaults when input is undefined", () => { + expect(resolveSettings(undefined)).toEqual(DEFAULT_SETTINGS); + }); + + it("falls back commandTimeoutMs when invalid", () => { + expect(resolveSettings({ commandTimeoutMs: -1 }).commandTimeoutMs).toBe(DEFAULT_SETTINGS.commandTimeoutMs); + }); + + it("filters non-string allowedDomains entries", () => { + expect(resolveSettings({ allowedDomains: ["ok.example.com", 42, true] }).allowedDomains).toEqual(["ok.example.com"]); + }); +}); diff --git a/plugins/fusion-plugin-agent-browser/src/__tests__/workflow-steps.test.ts b/plugins/fusion-plugin-agent-browser/src/__tests__/workflow-steps.test.ts new file mode 100644 index 000000000..40b6873bf --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/__tests__/workflow-steps.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { AGENT_BROWSER_WORKFLOW_STEPS } from "../workflow-steps.js"; + +describe("workflow steps", () => { + it("declares browser evidence review template", () => { + const step = AGENT_BROWSER_WORKFLOW_STEPS[0]; + expect(step?.stepId).toBe("browser-evidence-review"); + expect(step?.mode).toBe("prompt"); + }); +}); diff --git a/plugins/fusion-plugin-agent-browser/src/index.ts b/plugins/fusion-plugin-agent-browser/src/index.ts new file mode 100644 index 000000000..50bf5776c --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/index.ts @@ -0,0 +1,67 @@ +import { definePlugin } from "@fusion/plugin-sdk"; +import type { FusionPlugin, PluginSettingSchema } from "@fusion/plugin-sdk"; +import { AGENT_BROWSER_SKILLS } from "./skills.js"; +import { buildPromptContributions } from "./prompts.js"; +import { setupHooks, setupManifest } from "./setup.js"; +import { resolveSettings } from "./types.js"; +import { AGENT_BROWSER_WORKFLOW_STEPS } from "./workflow-steps.js"; +import { AGENT_BROWSER_TOOLS } from "./tools.js"; +import { probeAgentBrowserBinary } from "./probe.js"; + +const MAX_PROBE_TIMEOUT_MS = 30_000; + +export const AGENT_BROWSER_SETTINGS_SCHEMA: Record = { + enabled: { type: "boolean", label: "Enable Agent Browser", group: "General", defaultValue: true }, + installChannel: { type: "enum", label: "Install Channel", enumValues: ["stable", "beta", "nightly"], defaultValue: "stable", group: "General" }, + commandTimeoutMs: { type: "number", label: "Command Timeout (ms)", defaultValue: 120000, group: "General" }, + headlessMode: { type: "boolean", label: "Headless Mode", defaultValue: true, group: "Browser" }, + allowedDomains: { type: "array", label: "Allowed Domains", itemType: "string", defaultValue: [], group: "Browser" }, + promptExecutorSystem: { type: "string", label: "Executor System Prompt", multiline: true, defaultValue: "When browsing, summarize evidence with URLs.", group: "Prompt Contributions" }, + promptExecutorTask: { type: "string", label: "Executor Task Prompt", multiline: true, defaultValue: "Use browser context only when needed for the task.", group: "Prompt Contributions" }, + promptTriage: { type: "string", label: "Triage Prompt", multiline: true, defaultValue: "Mark tasks requiring browser evidence explicitly.", group: "Prompt Contributions" }, + promptReviewer: { type: "string", label: "Reviewer Prompt", multiline: true, defaultValue: "Verify browser-derived claims are backed by cited pages.", group: "Prompt Contributions" }, + promptHeartbeat: { type: "string", label: "Heartbeat Prompt", multiline: true, defaultValue: "Keep browser interactions bounded and report failures clearly.", group: "Prompt Contributions" }, + skillExposure: { type: "enum", label: "Skill Exposure", enumValues: ["none", "selected", "all"], defaultValue: "selected", group: "Skills" }, +}; + +const plugin: FusionPlugin = definePlugin({ + manifest: { + id: "fusion-plugin-agent-browser", + name: "Agent Browser Plugin", + version: "0.1.0", + description: "Adds agent-browser setup hooks plus skills, prompt contributions, and workflow steps", + author: "Fusion Team", + homepage: "https://github.com/gsxdsm/fusion", + settingsSchema: AGENT_BROWSER_SETTINGS_SCHEMA, + skills: AGENT_BROWSER_SKILLS.map((s) => ({ skillId: s.skillId, name: s.name })), + workflowSteps: AGENT_BROWSER_WORKFLOW_STEPS.map((s) => ({ stepId: s.stepId, name: s.name })), + promptSurfaces: ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"], + setup: setupManifest, + }, + state: "installed", + hooks: { + onLoad: async (ctx) => { + try { + const settings = resolveSettings(ctx.settings); + plugin.promptContributions = buildPromptContributions(settings); + const probe = await probeAgentBrowserBinary({ timeoutMs: Math.min(settings.commandTimeoutMs, MAX_PROBE_TIMEOUT_MS) }); + ctx.logger.info(`Agent Browser Plugin loaded — available=${String(probe.available)} channel=${settings.installChannel}`); + ctx.emitEvent("agent-browser:loaded", { available: probe.available, version: probe.version, settings }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.logger.error(`Agent Browser Plugin probe failed: ${message}`); + ctx.emitEvent("agent-browser:loaded", { available: false, error: message }); + } + }, + }, + tools: AGENT_BROWSER_TOOLS, + skills: AGENT_BROWSER_SKILLS, + workflowSteps: AGENT_BROWSER_WORKFLOW_STEPS, + promptContributions: buildPromptContributions(resolveSettings(undefined)), + setup: { + manifest: setupManifest, + hooks: setupHooks, + }, +}); + +export default plugin; diff --git a/plugins/fusion-plugin-agent-browser/src/probe.ts b/plugins/fusion-plugin-agent-browser/src/probe.ts new file mode 100644 index 000000000..1d9ae0814 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/probe.ts @@ -0,0 +1,98 @@ +import { spawn } from "node:child_process"; + +export interface AgentBrowserProbeResult { + available: boolean; + binaryPath?: string; + version?: string; + reason?: string; + notFound?: boolean; +} + +export async function probeAgentBrowserBinary(opts?: { binaryPath?: string; timeoutMs?: number }): Promise { + const binary = opts?.binaryPath?.trim() || "agent-browser"; + const timeoutMs = opts?.timeoutMs ?? 2000; + const resolvedPath = await tryResolveBinaryPath(binary); + + return new Promise((resolve) => { + let settled = false; + const child = spawn(resolvedPath ?? binary, ["--version"], { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + resolve({ available: false, binaryPath: resolvedPath, reason: `Probe timed out after ${timeoutMs}ms` }); + }, timeoutMs); + + child.stdout?.on("data", (c: Buffer) => (stdout += c.toString("utf-8"))); + child.stderr?.on("data", (c: Buffer) => (stderr += c.toString("utf-8"))); + child.on("error", (err: NodeJS.ErrnoException) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + available: false, + binaryPath: resolvedPath, + reason: err.code === "ENOENT" ? "`agent-browser` not found on PATH" : err.message, + notFound: err.code === "ENOENT", + }); + }); + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) { + resolve({ available: true, binaryPath: resolvedPath, version: stdout.trim() || undefined }); + } else { + resolve({ + available: false, + binaryPath: resolvedPath, + reason: stderr.trim() || `agent-browser --version exited with code ${String(code)}`, + }); + } + }); + }); +} + +async function tryResolveBinaryPath(binary: string): Promise { + return new Promise((resolvePromise) => { + const which = process.platform === "win32" ? "where" : "which"; + const child = spawn(which, [binary], { stdio: ["ignore", "pipe", "ignore"] }); + let out = ""; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + try { + child.kill("SIGKILL"); + } catch { + // ignored + } + resolvePromise(undefined); + }, 2000); + + child.stdout?.on("data", (chunk: Buffer) => { + out += chunk.toString("utf-8"); + }); + child.on("error", () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolvePromise(undefined); + }); + child.on("close", (code: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) { + const first = out.trim().split(/\r?\n/)[0]; + resolvePromise(first?.length ? first : undefined); + } else { + resolvePromise(undefined); + } + }); + }); +} diff --git a/plugins/fusion-plugin-agent-browser/src/prompts.ts b/plugins/fusion-plugin-agent-browser/src/prompts.ts new file mode 100644 index 000000000..6f1b5216f --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/prompts.ts @@ -0,0 +1,23 @@ +import type { PluginPromptContributions } from "@fusion/plugin-sdk"; + +export function buildPromptContributions(settings: { + promptExecutorSystem: string; + promptExecutorTask: string; + promptTriage: string; + promptReviewer: string; + promptHeartbeat: string; +}): PluginPromptContributions { + const seed = [ + { surface: "executor-system", content: settings.promptExecutorSystem }, + { surface: "executor-task", content: settings.promptExecutorTask }, + { surface: "triage", content: settings.promptTriage }, + { surface: "reviewer", content: settings.promptReviewer }, + { surface: "heartbeat", content: settings.promptHeartbeat }, + ] as const; + + const contributions: PluginPromptContributions["contributions"] = seed + .filter((p) => p.content.trim().length > 0) + .map((p) => ({ surface: p.surface, content: p.content })); + + return { enabledByDefault: false, contributions }; +} diff --git a/plugins/fusion-plugin-agent-browser/src/setup.ts b/plugins/fusion-plugin-agent-browser/src/setup.ts new file mode 100644 index 000000000..f908ce469 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/setup.ts @@ -0,0 +1,31 @@ +import type { PluginContext, PluginSetupCheckResult, PluginSetupHooks, PluginSetupManifest } from "@fusion/plugin-sdk"; +import { probeAgentBrowserBinary } from "./probe.js"; +import { resolveSettings } from "./types.js"; + +export const setupManifest: PluginSetupManifest = { + binaryName: "agent-browser", + description: "Headless browser runtime for web-enabled agents", + channel: "stable", + defaultTimeoutMs: 120000, +}; + +const MAX_PROBE_TIMEOUT_MS = 30_000; + +export async function checkSetup(ctx: PluginContext): Promise { + const settings = resolveSettings(ctx.settings); + const probe = await probeAgentBrowserBinary({ timeoutMs: Math.min(settings.commandTimeoutMs, MAX_PROBE_TIMEOUT_MS) }); + + if (probe.available) { + return { status: "installed", version: probe.version, binaryPath: probe.binaryPath }; + } + + const reason = probe.reason ?? "agent-browser probe failed"; + if (probe.notFound === true) { + return { status: "not-installed", error: reason }; + } + return { status: "error", error: reason }; +} + +export const setupHooks: PluginSetupHooks = { + checkSetup, +}; diff --git a/plugins/fusion-plugin-agent-browser/src/skills.ts b/plugins/fusion-plugin-agent-browser/src/skills.ts new file mode 100644 index 000000000..21bfeab53 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/skills.ts @@ -0,0 +1,12 @@ +import type { PluginSkillContribution } from "@fusion/plugin-sdk"; + +export const AGENT_BROWSER_SKILLS: PluginSkillContribution[] = [ + { + skillId: "agent-browser-navigation", + name: "Agent Browser Navigation", + description: "Navigate pages, collect evidence, and summarize findings.", + skillFiles: ["skills/agent-browser-navigation/SKILL.md"], + enabled: true, + triggerPatterns: ["browse", "visit website", "collect evidence"], + }, +]; diff --git a/plugins/fusion-plugin-agent-browser/src/tools.ts b/plugins/fusion-plugin-agent-browser/src/tools.ts new file mode 100644 index 000000000..757ba43a6 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/tools.ts @@ -0,0 +1,34 @@ +import type { PluginToolDefinition } from "@fusion/plugin-sdk"; +import { resolveSettings } from "./types.js"; + +export const AGENT_BROWSER_TOOLS: PluginToolDefinition[] = [ + { + name: "browser_fetch_metadata", + description: "Validate a URL against plugin allowlist and return safe fetch metadata.", + parameters: { + type: "object", + properties: { url: { type: "string" } }, + required: ["url"], + }, + execute: async (params, ctx) => { + const url = typeof params.url === "string" ? params.url : ""; + try { + const parsed = new URL(url); + const settings = resolveSettings(ctx.settings); + const allowed = settings.allowedDomains; + const isAllowed = allowed.length === 0 || allowed.some((d) => parsed.hostname === d || parsed.hostname.endsWith(`.${d}`)); + + if (!isAllowed) { + return { content: [{ type: "text", text: `Blocked by allowedDomains: ${parsed.hostname}` }], isError: true }; + } + + return { + content: [{ type: "text", text: `Allowed URL ${parsed.toString()} (headless=${String(settings.headlessMode)})` }], + details: { hostname: parsed.hostname, headlessMode: settings.headlessMode }, + }; + } catch { + return { content: [{ type: "text", text: "Invalid URL" }], isError: true }; + } + }, + }, +]; diff --git a/plugins/fusion-plugin-agent-browser/src/types.ts b/plugins/fusion-plugin-agent-browser/src/types.ts new file mode 100644 index 000000000..4d9b254e2 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/types.ts @@ -0,0 +1,56 @@ +export type SkillExposure = "none" | "selected" | "all"; + +export interface AgentBrowserSettings { + enabled: boolean; + installChannel: "stable" | "beta" | "nightly"; + commandTimeoutMs: number; + headlessMode: boolean; + allowedDomains: string[]; + promptExecutorSystem: string; + promptExecutorTask: string; + promptTriage: string; + promptReviewer: string; + promptHeartbeat: string; + skillExposure: SkillExposure; +} + +export const DEFAULT_SETTINGS: AgentBrowserSettings = { + enabled: true, + installChannel: "stable", + commandTimeoutMs: 120000, + headlessMode: true, + allowedDomains: [], + promptExecutorSystem: "When browsing, summarize evidence with URLs.", + promptExecutorTask: "Use browser context only when needed for the task.", + promptTriage: "Mark tasks requiring browser evidence explicitly.", + promptReviewer: "Verify browser-derived claims are backed by cited pages.", + promptHeartbeat: "Keep browser interactions bounded and report failures clearly.", + skillExposure: "selected", +}; + +export function resolveSettings(input: Record | undefined): AgentBrowserSettings { + const src = input ?? {}; + return { + enabled: typeof src.enabled === "boolean" ? src.enabled : DEFAULT_SETTINGS.enabled, + installChannel: + src.installChannel === "stable" || src.installChannel === "beta" || src.installChannel === "nightly" + ? src.installChannel + : DEFAULT_SETTINGS.installChannel, + commandTimeoutMs: + typeof src.commandTimeoutMs === "number" && Number.isFinite(src.commandTimeoutMs) && src.commandTimeoutMs > 0 + ? src.commandTimeoutMs + : DEFAULT_SETTINGS.commandTimeoutMs, + headlessMode: typeof src.headlessMode === "boolean" ? src.headlessMode : DEFAULT_SETTINGS.headlessMode, + allowedDomains: Array.isArray(src.allowedDomains) ? src.allowedDomains.filter((v): v is string => typeof v === "string") : [], + promptExecutorSystem: + typeof src.promptExecutorSystem === "string" ? src.promptExecutorSystem : DEFAULT_SETTINGS.promptExecutorSystem, + promptExecutorTask: typeof src.promptExecutorTask === "string" ? src.promptExecutorTask : DEFAULT_SETTINGS.promptExecutorTask, + promptTriage: typeof src.promptTriage === "string" ? src.promptTriage : DEFAULT_SETTINGS.promptTriage, + promptReviewer: typeof src.promptReviewer === "string" ? src.promptReviewer : DEFAULT_SETTINGS.promptReviewer, + promptHeartbeat: typeof src.promptHeartbeat === "string" ? src.promptHeartbeat : DEFAULT_SETTINGS.promptHeartbeat, + skillExposure: + src.skillExposure === "none" || src.skillExposure === "selected" || src.skillExposure === "all" + ? src.skillExposure + : DEFAULT_SETTINGS.skillExposure, + }; +} diff --git a/plugins/fusion-plugin-agent-browser/src/workflow-steps.ts b/plugins/fusion-plugin-agent-browser/src/workflow-steps.ts new file mode 100644 index 000000000..5eabda75c --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/src/workflow-steps.ts @@ -0,0 +1,15 @@ +import type { PluginWorkflowStepContribution } from "@fusion/plugin-sdk"; + +export const AGENT_BROWSER_WORKFLOW_STEPS: PluginWorkflowStepContribution[] = [ + { + stepId: "browser-evidence-review", + name: "Browser Evidence Review", + description: "Verify claims include browser-derived evidence and links.", + mode: "prompt", + phase: "pre-merge", + prompt: "Confirm browser-derived statements are traceable to captured evidence and cite links when present.", + toolMode: "readonly", + enabled: true, + defaultOn: false, + }, +]; diff --git a/plugins/fusion-plugin-agent-browser/tsconfig.json b/plugins/fusion-plugin-agent-browser/tsconfig.json new file mode 100644 index 000000000..a5a86f473 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*"] +} diff --git a/plugins/fusion-plugin-agent-browser/vitest.config.ts b/plugins/fusion-plugin-agent-browser/vitest.config.ts new file mode 100644 index 000000000..7f8fb8a97 --- /dev/null +++ b/plugins/fusion-plugin-agent-browser/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; +import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers"; + +const maxWorkers = computeMaxWorkers(); + +export default defineConfig({ + resolve: { + alias: { + "@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)), + "@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)), + }, + }, + test: { + include: ["src/**/*.test.ts"], + setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))], + globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))], + pool: "threads", + maxWorkers, + poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 722bedf6b..dfaec361a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -593,6 +593,22 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3) + plugins/fusion-plugin-agent-browser: + dependencies: + '@fusion/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + devDependencies: + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3) + plugins/fusion-plugin-dependency-graph: dependencies: '@fusion/core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 472e55655..d77aaf54d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,3 +6,4 @@ packages: - "plugins/fusion-plugin-openclaw-runtime" - "plugins/fusion-plugin-hermes-runtime" - "plugins/fusion-plugin-droid-runtime" + - "plugins/fusion-plugin-agent-browser"