From 82d798bf15c003469e06cf5fe3c0be5184f56db0 Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Wed, 30 Sep 2026 17:46:51 +0200 Subject: [PATCH 1/2] Suppress Lightpanda startup notices --- lightpanda.test.ts | 2 ++ lightpanda.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lightpanda.test.ts b/lightpanda.test.ts index 47efaab..e54d7b0 100644 --- a/lightpanda.test.ts +++ b/lightpanda.test.ts @@ -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", diff --git a/lightpanda.ts b/lightpanda.ts index 28badce..3235787 100644 --- a/lightpanda.ts +++ b/lightpanda.ts @@ -90,6 +90,8 @@ async function fetchPage(input: unknown, context: ToolContext) { "--block-private-networks", "--log-level", "error", + "--log-filter", + "note", ] const child = Bun.spawn(command, { stderr: "pipe", From a0012850c58cf6a39caa328d004cd06535a42ef1 Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Wed, 30 Sep 2026 17:47:17 +0200 Subject: [PATCH 2/2] Report structured Lightpanda fetch failures --- README.md | 1 + lightpanda.test.ts | 25 +++++++++++++++++++++++++ lightpanda.ts | 25 ++++++++++++++++++++++++- test/fixtures/lightpanda | 16 +++++++++++++++- 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5752ba8..06536a2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lightpanda.test.ts b/lightpanda.test.ts index e54d7b0..c7f3fb5 100644 --- a/lightpanda.test.ts +++ b/lightpanda.test.ts @@ -92,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"], diff --git a/lightpanda.ts b/lightpanda.ts index 3235787..7261d22 100644 --- a/lightpanda.ts +++ b/lightpanda.ts @@ -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(), @@ -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({ @@ -108,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) { @@ -136,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 + } +} diff --git a/test/fixtures/lightpanda b/test/fixtures/lightpanda index e2526a3..00e140f 100755 --- a/test/fixtures/lightpanda +++ b/test/fixtures/lightpanda @@ -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))