Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,22 @@ A Lightpanda browser plugin/tool for OpenCode. It is like WebFetch tool on stero

## Requirements

- [OpenCode](https://opencode.ai/)
- [OpenCode V2](https://opencode.ai/v2/docs/) (2.0.18 or newer)
- [Lightpanda](https://lightpanda.io/docs/run-locally/installation/one-liner)
- [Bun](https://bun.sh/) for development and tests

## Install

Install Lightpanda, then install the plugin globally:
Install Lightpanda, then add the plugin globally:

```sh
opencode plugin -g opencode-lightpanda@latest
opencode plugin add opencode-lightpanda@latest
```

To update an existing installation:

```sh
opencode plugin -g -f opencode-lightpanda@latest
opencode plugin update opencode-lightpanda
```

Quit and restart OpenCode.
Expand Down Expand Up @@ -54,22 +54,38 @@ bun run check
bun test
```

For an end-to-end smoke test, run `bun run test:smoke`. It requires OpenCode V2 on `PATH` and a working default model (or set `OPENCODE_SMOKE_MODEL=provider/model#variant`). The test starts a private OpenCode server in a temporary project, loads this checkout, and calls the tool using the bundled Lightpanda fixture. It does not need network access to fetch a page, but it does make a model request, so it is not part of the regular test suite or CI.

Load the checkout directly by adding it to `~/.config/opencode/opencode.json`:

```json
{
"plugin": ["file:///absolute/path/to/opencode-lightpanda/lightpanda.ts"]
"$schema": "https://opencode.ai/config.json",
"plugins": ["/absolute/path/to/opencode-lightpanda"]
}
```

## Behavior

- Adds a distinct `lightpanda` tool with its own permission action.
- Returns an error for non-2xx responses and responses over 5 MB.
- Blocks private-network requests, including subresources initiated by page JavaScript.
- Disables Lightpanda telemetry unless `LIGHTPANDA_DISABLE_TELEMETRY` is already set.

This intentionally does not include web search, stateful CDP sessions, or browser interaction tools. Lightpanda's MCP server already covers those use cases without expanding a fetch replacement into a second browser harness.

## Config
The tools can be controlled independently:

The tools can be controlled independently. To request approval before using Lightpanda, configure V2 permissions:

```json
{
"permission": {
"webfetch": "deny",
"lightpanda": "allow"
}
"$schema": "https://opencode.ai/config.json",
"permissions": [
{ "action": "webfetch", "resource": "*", "effect": "deny" },
{ "action": "lightpanda", "resource": "*", "effect": "ask" }
]
}
```

V2 custom tool permissions apply to the tool as a whole (`resource: "*"`); unlike the V1 plugin, it cannot request approval for each individual URL. Without an explicit rule, V2's default policy allows the tool. Set `effect` to `allow` or `deny` instead of `ask` to change that behavior.
544 changes: 532 additions & 12 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./lightpanda"
85 changes: 62 additions & 23 deletions lightpanda.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,45 @@
import { expect, test } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin"
import plugin from "./lightpanda"
import type { Plugin } from "@opencode/plugin"
import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/tool"
import plugin from "./index"

process.env.LIGHTPANDA_BIN = `${import.meta.dir}/test/fixtures/lightpanda`
const { lightpanda } = (await plugin()).tool

test("constructs the command and asks for lightpanda permission", async () => {
let permission: Parameters<ToolContext["ask"]>[0] | undefined
const tools: Info[] = []
await plugin.setup({
tool: {
async transform(callback: (editor: ToolEditor) => void) {
callback({ add: (tool: Info) => void tools.push(tool) } as ToolEditor)
return { async dispose() {} }
},
},
} as Plugin.Context)
const lightpanda = tools[0]!

test("registers the V2 lightpanda tool and permission action", () => {
expect(plugin.id).toBe("opencode-lightpanda")
expect(tools).toHaveLength(1)
expect(lightpanda.name).toBe("lightpanda")
expect(lightpanda.options?.permission).toBe("lightpanda")
expect(lightpanda.input).toMatchObject({
type: "object",
required: ["url"],
properties: {
url: { type: "string", format: "uri", pattern: "^https?://" },
format: { enum: ["markdown", "json", "semantic_tree"] },
timeout: { exclusiveMinimum: 0, maximum: 120 },
},
})
})

test("constructs the command and returns V2 content and metadata", async () => {
const result = await lightpanda.execute(
{ url: "https://example.test/command", format: "json", timeout: 2 },
makeContext({ ask: async (input) => void (permission = input) }),
makeContext(),
)

if (typeof result === "string") throw new Error("Expected a structured tool result")
expect(JSON.parse(result.output)).toEqual([
if (typeof result.content !== "string") throw new Error("Expected text content")
expect(JSON.parse(result.content)).toEqual([
"fetch",
"https://example.test/command",
"--dump",
Expand All @@ -29,7 +55,25 @@ test("constructs the command and asks for lightpanda permission", async () => {
"--log-level",
"error",
])
expect(permission?.permission).toBe("lightpanda")
expect(result.metadata).toEqual({
backend: "lightpanda",
format: "json",
httpStatus: 200,
url: "https://example.test/command",
contentType: "text/plain",
})
})

test("defaults to markdown and a 30-second timeout", async () => {
const result = await lightpanda.execute({ url: "https://example.test/command" }, makeContext())
if (typeof result.content !== "string") throw new Error("Expected text content")
const args = JSON.parse(result.content) as string[]
expect(args.slice(args.indexOf("--dump"), args.indexOf("--json"))).toEqual(["--dump", "markdown"])
expect(args[args.indexOf("--terminate-ms") + 1]).toBe("30000")
})

test.each(["file:///etc/passwd", "not-a-url"])("rejects non-HTTP URLs: %s", (url) => {
return expect(lightpanda.execute({ url }, makeContext())).rejects.toThrow("fully qualified HTTP or HTTPS URL")
})

test.each([
Expand All @@ -41,23 +85,18 @@ test.each([
] as const)("%s", (_, path, timeout, error, abort) => {
const request = lightpanda.execute(
{ url: `https://example.test/${path}`, timeout },
makeContext({ abort: abort ? AbortSignal.timeout(10) : undefined }),
makeContext(abort ? AbortSignal.timeout(10) : undefined),
)
return expect(request).rejects.toThrow(error)
})

function makeContext({
ask = async () => {},
abort = new AbortController().signal,
}: { ask?: ToolContext["ask"]; abort?: AbortSignal } = {}) {
function makeContext(signal = new AbortController().signal): ToolContext {
return {
sessionID: "test",
messageID: "test",
agent: "test",
directory: process.cwd(),
worktree: process.cwd(),
abort,
metadata() {},
ask,
} satisfies ToolContext
sessionID: "test" as ToolContext["sessionID"],
messageID: "test" as ToolContext["messageID"],
agent: "test" as ToolContext["agent"],
id: "test" as ToolContext["id"],
signal,
async progress() {},
}
}
Loading
Loading