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",
|
"opencode-poe-auth": "0.0.1",
|
||||||
"opentui-spinner": "catalog:",
|
"opentui-spinner": "catalog:",
|
||||||
"partial-json": "0.1.7",
|
"partial-json": "0.1.7",
|
||||||
|
"playwright-core": "1.62.1",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
|
|||||||
@@ -45,22 +45,13 @@ function hostOf(url: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installGuard() {
|
export function egressCheck(url: string) {
|
||||||
const mode = process.env.NEURON_GUARD ?? "log"
|
const mode = process.env.NEURON_GUARD ?? "log"
|
||||||
const strict = mode === "strict"
|
const strict = mode === "strict"
|
||||||
const allow = (process.env.NEURON_GUARD_ALLOW ?? "")
|
const allow = (process.env.NEURON_GUARD_ALLOW ?? "")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((h) => h.trim())
|
.map((h) => h.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
const original = globalThis.fetch
|
|
||||||
const guarded = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const url =
|
|
||||||
typeof input === "string"
|
|
||||||
? input
|
|
||||||
: input instanceof URL
|
|
||||||
? input.href
|
|
||||||
: (input && "url" in input && typeof input.url === "string" ? input.url : String(input))
|
|
||||||
const host = hostOf(url)
|
const host = hostOf(url)
|
||||||
const entry: Entry = { t: new Date().toISOString(), host, url }
|
const entry: Entry = { t: new Date().toISOString(), host, url }
|
||||||
if (strict && !allow.includes(host)) {
|
if (strict && !allow.includes(host)) {
|
||||||
@@ -73,6 +64,18 @@ export function installGuard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log(entry)
|
log(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installGuard() {
|
||||||
|
const original = globalThis.fetch
|
||||||
|
const guarded = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: (input && "url" in input && typeof input.url === "string" ? input.url : String(input))
|
||||||
|
egressCheck(url)
|
||||||
return original(input, init)
|
return original(input, init)
|
||||||
}
|
}
|
||||||
// preserve fetch-adjacent properties attached by the runtime
|
// 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