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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ Load the checkout directly by adding it to `~/.config/opencode/opencode.json`:

- Adds a distinct `lightpanda` tool with its own permission action.
- Returns an error for non-2xx responses and responses over 5 MB.
- Reports concise TLS/DNS fetch failures from Lightpanda's JSON diagnostics, with a bounded stderr fallback.
- Blocks private-network requests, including subresources initiated by page JavaScript.
- Disables Lightpanda telemetry unless `LIGHTPANDA_DISABLE_TELEMETRY` is already set.

Expand Down
27 changes: 27 additions & 0 deletions lightpanda.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ test("constructs the command and returns V2 content and metadata", async () => {
"--block-private-networks",
"--log-level",
"error",
"--log-filter",
"note",
])
expect(result.metadata).toEqual({
backend: "lightpanda",
Expand Down Expand Up @@ -90,6 +92,31 @@ test.each([
return expect(request).rejects.toThrow(error)
})

test.each([
["tls-error", "TLS handshake failed (SslConnectError)."],
["dns-error", "DNS lookup failed (CouldntResolveHost)."],
] as const)("uses structured %s diagnostics instead of stderr", async (path, message) => {
await expect(lightpanda.execute({ url: `https://example.test/${path}` }, makeContext())).rejects.toThrow(
`Lightpanda could not fetch https://example.test/${path}:\n${message} No HTTP response received.`,
)
})

test("falls back to a bounded stderr excerpt when failure JSON is invalid", async () => {
const error = await lightpanda.execute({ url: "https://example.test/bad-failure-json" }, makeContext()).catch(
(error: Error) => error,
)
if (!(error instanceof Error)) throw new Error("Expected fetch to fail")
expect(error.message).toStartWith("Lightpanda could not fetch https://example.test/bad-failure-json:\nraw failure ")
expect(error.message.length).toBeLessThan(400)
expect(error.message).not.toContain("startup notice")
})

test("reports the exit status when neither JSON nor stderr has a diagnostic", () => {
return expect(lightpanda.execute({ url: "https://example.test/empty-failure" }, makeContext())).rejects.toThrow(
"Lightpanda could not fetch https://example.test/empty-failure:\nLightpanda exited with status 1",
)
})

function makeContext(signal = new AbortController().signal): ToolContext {
return {
sessionID: "test" as ToolContext["sessionID"],
Expand Down
27 changes: 26 additions & 1 deletion lightpanda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { z } from "zod"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024
const MAX_TIMEOUT_SECONDS = 120
const PROCESS_GRACE_MS = 1_000
const MAX_ERROR_EXCERPT = 300

const responseSchema = z.object({
url: z.string(),
Expand All @@ -13,6 +14,11 @@ const responseSchema = z.object({
content: z.string(),
})

const failureSchema = z.object({
error: z.string().trim().min(1),
http_status: z.number().optional(),
})

type FetchInput = { url: string; format?: "markdown" | "json" | "semantic_tree"; timeout?: number }

export default Plugin.define({
Expand Down Expand Up @@ -90,6 +96,8 @@ async function fetchPage(input: unknown, context: ToolContext) {
"--block-private-networks",
"--log-level",
"error",
"--log-filter",
"note",
]
const child = Bun.spawn(command, {
stderr: "pipe",
Expand All @@ -106,10 +114,18 @@ async function fetchPage(input: unknown, context: ToolContext) {
])
if (context.signal.aborted) throw new Error("Request aborted")
if (timeoutSignal.aborted) throw new Error(`Request timed out after ${timeout} seconds`)
if (exitCode !== 0) throw new Error(stderr.trim() || `Lightpanda exited with status ${exitCode}`)
if (Buffer.byteLength(stdout) > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
if (exitCode !== 0) {
const failure = parseFailure(stdout)
const code = failure?.error.replace(/\s+/g, " ").slice(0, MAX_ERROR_EXCERPT)
const label = code === "SslConnectError" ? "TLS handshake failed" : code === "CouldntResolveHost" ? "DNS lookup failed" : "Fetch failed"
const detail = failure
? `${label} (${code}).${failure.http_status === 0 ? " No HTTP response received." : ""}`
: stderr.trim().replace(/\s+/g, " ").slice(0, MAX_ERROR_EXCERPT) || `Lightpanda exited with status ${exitCode}`
throw new Error(`Lightpanda could not fetch ${url}:\n${detail}`)
}

const response = parseResponse(stdout)
if (response.http_status < 200 || response.http_status >= 300) {
Expand All @@ -134,3 +150,12 @@ function parseResponse(output: string) {
if (!response.success) throw new Error("Lightpanda returned an unexpected response")
return response.data
}

function parseFailure(output: string) {
try {
const response = failureSchema.safeParse(JSON.parse(output))
return response.success ? response.data : undefined
} catch {
return undefined
}
}
16 changes: 15 additions & 1 deletion test/fixtures/lightpanda
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,21 @@
const args = process.argv.slice(2)
const url = new URL(args[1])

if (url.pathname === "/malformed") {
if (url.pathname === "/tls-error" || url.pathname === "/dns-error") {
process.stdout.write(JSON.stringify({
url: url.href,
http_status: 0,
error: url.pathname === "/tls-error" ? "SslConnectError" : "CouldntResolveHost",
}))
process.stderr.write("startup notice and verbose raw failure details\n")
process.exitCode = 1
} else if (url.pathname === "/bad-failure-json") {
process.stdout.write("{")
process.stderr.write(`raw failure ${"x".repeat(1000)}\nstartup notice`)
process.exitCode = 1
} else if (url.pathname === "/empty-failure") {
process.exitCode = 1
} else if (url.pathname === "/malformed") {
process.stdout.write("{")
} else if (url.pathname === "/oversized") {
process.stdout.write("x".repeat(5 * 1024 * 1024 + 1))
Expand Down
Loading