diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 419f8ffa4b..b1eebd0f99 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -134,6 +134,7 @@ "opencode-poe-auth": "0.0.1", "opentui-spinner": "catalog:", "partial-json": "0.1.7", + "playwright-core": "1.62.1", "remeda": "catalog:", "semver": "^7.6.3", "solid-js": "catalog:", diff --git a/packages/opencode/src/guard/index.ts b/packages/opencode/src/guard/index.ts index 1e151ca4cc..974221f4c3 100644 --- a/packages/opencode/src/guard/index.ts +++ b/packages/opencode/src/guard/index.ts @@ -45,14 +45,28 @@ function hostOf(url: string): string { } } -export function installGuard() { +export function egressCheck(url: string) { const mode = process.env.NEURON_GUARD ?? "log" const strict = mode === "strict" const allow = (process.env.NEURON_GUARD_ALLOW ?? "") .split(",") .map((h) => h.trim()) .filter(Boolean) + const host = hostOf(url) + const entry: Entry = { t: new Date().toISOString(), host, url } + if (strict && !allow.includes(host)) { + for (const denied of DENYLIST) { + if (host === denied || host.endsWith("." + denied) || url.includes(denied)) { + entry.blocked = true + log(entry) + throw new Error(`Neuron guard: egress to ${denied} is blocked (NEURON_GUARD=strict)`) + } + } + } + log(entry) +} +export function installGuard() { const original = globalThis.fetch const guarded = async (input: RequestInfo | URL, init?: RequestInit) => { const url = @@ -61,18 +75,7 @@ export function installGuard() { : input instanceof URL ? input.href : (input && "url" in input && typeof input.url === "string" ? input.url : String(input)) - const host = hostOf(url) - const entry: Entry = { t: new Date().toISOString(), host, url } - if (strict && !allow.includes(host)) { - for (const denied of DENYLIST) { - if (host === denied || host.endsWith("." + denied) || url.includes(denied)) { - entry.blocked = true - log(entry) - throw new Error(`Neuron guard: egress to ${denied} is blocked (NEURON_GUARD=strict)`) - } - } - } - log(entry) + egressCheck(url) return original(input, init) } // preserve fetch-adjacent properties attached by the runtime diff --git a/packages/opencode/src/tool/browser.ts b/packages/opencode/src/tool/browser.ts new file mode 100644 index 0000000000..f693aa2cdc --- /dev/null +++ b/packages/opencode/src/tool/browser.ts @@ -0,0 +1,132 @@ +import { Effect, Schema } from "effect" +import { chromium, type Browser, type Page } from "playwright-core" +import * as Tool from "./tool" +import { egressCheck } from "@/guard" +import DESCRIPTION from "./browser.txt" + +const MAX_CONTENT = 20_000 + +const Parameters = Schema.Struct({ + action: Schema.Literals(["navigate", "click", "fill", "content", "screenshot", "close"]).annotate({ + description: "Browser operation to perform", + }), + url: Schema.optional(Schema.String).annotate({ description: "Absolute URL for navigate" }), + selector: Schema.optional(Schema.String).annotate({ description: "CSS selector for click/fill" }), + text: Schema.optional(Schema.String).annotate({ description: "Text to type for fill" }), + path: Schema.optional(Schema.String).annotate({ description: "Absolute PNG path for screenshot" }), + all: Schema.optional(Schema.Boolean).annotate({ + description: "For close: true closes the whole browser, false (default) closes only the active tab", + default: false, + }), +}) + +let browserPromise: Promise | undefined +let active: Page | undefined + +const getBrowser = () => + Effect.promise(async () => { + if (!browserPromise) { + browserPromise = chromium.launch({ channel: "chrome", headless: true }) + } + return browserPromise + }) + +const getPage = () => + Effect.gen(function* () { + const browser = yield* getBrowser() + if (!active || active.isClosed()) { + active = yield* Effect.promise(() => browser.newPage()) + } + return active + }) + +const visibleText = (page: Page) => + Effect.tryPromise({ + try: async () => { + const text = await page.evaluate(() => document.body?.innerText ?? "") + const title = await page.title() + const trimmed = text.length > MAX_CONTENT ? `${text.slice(0, MAX_CONTENT)}\n...[truncated]` : text + return `# ${title}\n\n${trimmed}` + }, + catch: (error) => new Error(error instanceof Error ? error.message : String(error)), + }).pipe(Effect.orDie) + +export const BrowserTool = Tool.define( + "browser", + Effect.gen(function* () { + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, _ctx: Tool.Context) => + Effect.gen(function* () { + switch (params.action) { + case "navigate": { + if (!params.url) throw new Error("url is required for navigate") + if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) + throw new Error("URL must start with http:// or https://") + egressCheck(params.url) + const page = yield* getPage() + yield* Effect.tryPromise({ + try: () => page.goto(params.url!, { waitUntil: "domcontentloaded", timeout: 45_000 }), + catch: (error) => new Error(error instanceof Error ? error.message : String(error)), + }).pipe(Effect.orDie) + const body = yield* visibleText(page) + return { title: `navigate ${params.url}`, metadata: {}, output: body } + } + + case "click": { + if (!params.selector) throw new Error("selector is required for click") + const page = yield* getPage() + yield* Effect.tryPromise({ + try: () => page.click(params.selector!, { timeout: 10_000 }), + catch: (error) => new Error(error instanceof Error ? error.message : String(error)), + }).pipe(Effect.orDie) + yield* Effect.promise(() => page.waitForLoadState("domcontentloaded").catch(() => undefined)) + return { title: `click ${params.selector}`, metadata: {}, output: "clicked" } + } + + case "fill": { + if (!params.selector || params.text === undefined) throw new Error("selector and text are required for fill") + const page = yield* getPage() + yield* Effect.tryPromise({ + try: () => page.fill(params.selector!, params.text!, { timeout: 10_000 }), + catch: (error) => new Error(error instanceof Error ? error.message : String(error)), + }).pipe(Effect.orDie) + return { title: `fill ${params.selector}`, metadata: {}, output: "filled" } + } + + case "content": { + const page = yield* getPage() + const body = yield* visibleText(page) + return { title: `content ${page.url()}`, metadata: {}, output: body } + } + + case "screenshot": { + if (!params.path) throw new Error("path is required for screenshot") + const page = yield* getPage() + yield* Effect.tryPromise({ + try: () => page.screenshot({ path: params.path!, fullPage: false }), + catch: (error) => new Error(error instanceof Error ? error.message : String(error)), + }).pipe(Effect.orDie) + return { title: `screenshot ${params.path}`, metadata: {}, output: `saved to ${params.path}` } + } + + case "close": { + if (!active) return { title: "close", metadata: {}, output: "no active page" } + const page = active + if (params.all) { + const browser = yield* getBrowser() + yield* Effect.promise(() => browser.close()) + browserPromise = undefined + active = undefined + return { title: "close", metadata: {}, output: "browser closed" } + } + yield* Effect.promise(() => page.close()) + active = undefined + return { title: "close", metadata: {}, output: "tab closed" } + } + } + }), + } + }), +) diff --git a/packages/opencode/src/tool/browser.txt b/packages/opencode/src/tool/browser.txt new file mode 100644 index 0000000000..e2d9aed6d7 --- /dev/null +++ b/packages/opencode/src/tool/browser.txt @@ -0,0 +1,19 @@ +Drive a real headless Chrome browser. + +One browser instance is shared across calls in this session; pages persist +between calls, so you can navigate once and then click, fill, and read on +subsequent calls. Every navigation passes through the Neuron egress guard — +in NEURON_GUARD=strict mode, denylisted hosts are refused. + +Actions: + +- `navigate` (url): open a URL in a new tab and make it active. Returns the + page title and readable text. +- `click` (selector): click an element by CSS selector. +- `fill` (selector, text): clear and type text into an input/textarea. +- `content`: return the active page's visible text (truncated). +- `screenshot` (path): save a PNG of the active page to an absolute path. +- `close`: close the active tab (or the whole browser with `all: true`). + +Selectors are plain CSS (`#id`, `.class`, `a[href*="docs"]`). Prefer reading +`content` before clicking blind — know the page before you act on it.