feat(FN-3573): broaden runtime ignore list for live fusion app paths (+2 more)
Commits merged: - chore(test-isolation): broaden runtime ignore list for live fusion app paths - feat(FN-3573): complete Step 2 — add plugin setup API client helpers - feat(FN-3573): complete Step 1 — backfill plugin setup routes Files changed: plugins/fusion-plugin-agent-browser/README.md | 26 ++++++ plugins/fusion-plugin-agent-browser/manifest.json | 15 ++++ plugins/fusion-plugin-agent-browser/package.json | 25 ++++++ .../src/__tests__/index.test.ts | 66 +++++++++++++++ .../src/__tests__/prompts.test.ts | 21 +++++ .../src/__tests__/setup.test.ts | 39 +++++++++ .../src/__tests__/skills.test.ts | 9 ++ .../src/__tests__/tools.test.ts | 55 ++++++++++++ .../src/__tests__/types.test.ts | 16 ++++ .../src/__tests__/workflow-steps.test.ts | 10 +++ plugins/fusion-plugin-agent-browser/src/index.ts | 67 +++++++++++++++ plugins/fusion-plugin-agent-browser/src/probe.ts | 98 ++++++++++++++++++++++ plugins/fusion-plugin-agent-browser/src/prompts.ts | 23 +++++ plugins/fusion-plugin-agent-browser/src/setup.ts | 31 +++++++ plugins/fusion-plugin-agent-browser/src/skills.ts | 12 +++ plugins/fusion-plugin-agent-browser/src/tools.ts | 34 ++++++++ plugins/fusion-plugin-agent-browser/src/types.ts | 56 +++++++++++++ .../src/workflow-steps.ts | 15 ++++ plugins/fusion-plugin-agent-browser/tsconfig.json | 9 ++ .../fusion-plugin-agent-browser/vitest.config.ts | 22 +++++ pnpm-lock.yaml | 16 ++++ pnpm-workspace.yaml | 1 + 22 files changed, 666 insertions(+) Fusion-Task-Id: FN-3573
This commit is contained in:
26
plugins/fusion-plugin-agent-browser/README.md
Normal file
26
plugins/fusion-plugin-agent-browser/README.md
Normal file
@@ -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`.
|
||||
15
plugins/fusion-plugin-agent-browser/manifest.json
Normal file
15
plugins/fusion-plugin-agent-browser/manifest.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
25
plugins/fusion-plugin-agent-browser/package.json
Normal file
25
plugins/fusion-plugin-agent-browser/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
67
plugins/fusion-plugin-agent-browser/src/index.ts
Normal file
67
plugins/fusion-plugin-agent-browser/src/index.ts
Normal file
@@ -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<string, PluginSettingSchema> = {
|
||||
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;
|
||||
98
plugins/fusion-plugin-agent-browser/src/probe.ts
Normal file
98
plugins/fusion-plugin-agent-browser/src/probe.ts
Normal file
@@ -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<AgentBrowserProbeResult> {
|
||||
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<string | undefined> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
23
plugins/fusion-plugin-agent-browser/src/prompts.ts
Normal file
23
plugins/fusion-plugin-agent-browser/src/prompts.ts
Normal file
@@ -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 };
|
||||
}
|
||||
31
plugins/fusion-plugin-agent-browser/src/setup.ts
Normal file
31
plugins/fusion-plugin-agent-browser/src/setup.ts
Normal file
@@ -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<PluginSetupCheckResult> {
|
||||
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,
|
||||
};
|
||||
12
plugins/fusion-plugin-agent-browser/src/skills.ts
Normal file
12
plugins/fusion-plugin-agent-browser/src/skills.ts
Normal file
@@ -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"],
|
||||
},
|
||||
];
|
||||
34
plugins/fusion-plugin-agent-browser/src/tools.ts
Normal file
34
plugins/fusion-plugin-agent-browser/src/tools.ts
Normal file
@@ -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 };
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
56
plugins/fusion-plugin-agent-browser/src/types.ts
Normal file
56
plugins/fusion-plugin-agent-browser/src/types.ts
Normal file
@@ -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<string, unknown> | 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,
|
||||
};
|
||||
}
|
||||
15
plugins/fusion-plugin-agent-browser/src/workflow-steps.ts
Normal file
15
plugins/fusion-plugin-agent-browser/src/workflow-steps.ts
Normal file
@@ -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,
|
||||
},
|
||||
];
|
||||
9
plugins/fusion-plugin-agent-browser/tsconfig.json
Normal file
9
plugins/fusion-plugin-agent-browser/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
22
plugins/fusion-plugin-agent-browser/vitest.config.ts
Normal file
22
plugins/fusion-plugin-agent-browser/vitest.config.ts
Normal file
@@ -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 } },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user