feat(opencode): guard refactor + browser tool sources and playwright-core dep
This commit is contained in:
@@ -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:",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Browser> | 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<typeof Parameters>, _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" }
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user