@@ -987,15 +1025,6 @@ export default function SessionBrowserPane(props: {
)
}
-/** A page's icon, or the globe while it has none. */
-function PageIcon(props: { icon: string | undefined }) {
- return (
-
}>
- {(icon) =>
})
}
-
- )
-}
-
/** A page in the suggestions or the new tab page: its icon, title and address, with the query's matches drawn. */
function PageRow(props: {
visit: Visit
@@ -1006,7 +1035,7 @@ function PageRow(props: {
}) {
const body = () => (
<>
-
+
diff --git a/packages/gui-extensions/src/browser/policy.test.ts b/packages/gui-extensions/src/browser/policy.test.ts
index 7f2481dfcf7..d6467ca731b 100644
--- a/packages/gui-extensions/src/browser/policy.test.ts
+++ b/packages/gui-extensions/src/browser/policy.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
-import { allowedDestination, destinationOrigin, fileURLWithin, localFileURL, normalizeURL } from "./policy"
+import { allowedDestination, destinationOrigin, fileURLWithin, localFileURL, normalizeURL, refusal } from "./policy"
test("allows cross-origin HTTP navigation but rejects unsafe destinations and embedded credentials", () => {
expect(destinationOrigin("https://other.example/path")).toBe("https://other.example")
@@ -39,6 +39,37 @@ test("file documents load only from allowed workspace roots and never from a hos
expect(() => normalizeURL("file:///home/me/repo/x.html")).toThrow()
expect(() => normalizeURL("file:///etc/passwd", { fileRoots: roots })).toThrow()
expect(normalizeURL("localhost:3000", { fileRoots: roots })).toBe("http://localhost:3000")
+ expect(normalizeURL("app.localhost:3000/")).toBe("http://app.localhost:3000/")
+ expect(normalizeURL(" ABOUT:BLANK ")).toBe("about:blank")
+})
+
+test("the pane names why it refuses an address, so the window can explain it", () => {
+ const roots = ["/home/me/repo"]
+
+ expect(
+ [
+ "example.com",
+ "about:blank",
+ "file:///home/me/repo/x.html",
+ "https://user:pass@example.com",
+ "file:///etc/passwd",
+ "file://server/home/me/repo/x.html",
+ "ftp://example.com",
+ "about:config",
+ ].map((url) => refusal(url, { fileRoots: roots })),
+ ).toEqual([
+ undefined,
+ undefined,
+ undefined,
+ "browser.address.credentials",
+ "browser.address.workspace",
+ "browser.address.workspace",
+ "browser.address.workspace",
+ "browser.address.workspace",
+ ])
+ // Where no files open, only web pages do.
+ expect(refusal("file:///home/me/repo/x.html")).toBe("browser.address.web")
+ expect(() => normalizeURL("https://user:pass@example.com")).toThrow("user name or password")
})
test("windows workspace roots match drive-letter file URLs", () => {
diff --git a/packages/gui-extensions/src/browser/policy.ts b/packages/gui-extensions/src/browser/policy.ts
index f2a45aa6480..284e5d3cc6a 100644
--- a/packages/gui-extensions/src/browser/policy.ts
+++ b/packages/gui-extensions/src/browser/policy.ts
@@ -49,13 +49,13 @@ export function allowedDestination(input: string, policy?: Policy) {
}
export function normalizeURL(input: string, policy?: Policy) {
- const value = input.trim() || "about:blank"
- const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value)
+ const url = withScheme(input)
+ const reason = refusal(url, policy)
- const url =
- value === "about:blank" || /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `${local ? "http" : "https"}://${value}`
+ if (reason === "browser.address.credentials")
+ throw new Error("URLs with a user name or password are not supported. Remove them from the URL.")
- if (url !== "about:blank" && !allowedDestination(url, policy))
+ if (reason)
throw new Error(
policy?.fileRoots?.length
? "Only HTTP, HTTPS, about:blank, and file URLs inside the workspace are supported."
@@ -64,3 +64,32 @@ export function normalizeURL(input: string, policy?: Policy) {
return url
}
+
+/**
+ * Why the pane does not open an address, as the code the window explains it with; undefined when it opens.
+ * - `browser.address.credentials`: a web page address with a user name or password.
+ * - `browser.address.workspace`: anything but a web page or a file in the workspace.
+ * - `browser.address.web`: anything but a web page, where no files open.
+ */
+export function refusal(input: string, policy?: Policy) {
+ const url = withScheme(input)
+
+ if (url === "about:blank" || allowedDestination(url, policy)) return
+
+ // A web page is refused only for its credentials.
+ if (URL.canParse(url) && /^https?:$/.test(new URL(url).protocol)) return "browser.address.credentials"
+
+ return policy?.fileRoots?.length ? "browser.address.workspace" : "browser.address.web"
+}
+
+// The URL an address opens: a blank page for none, else the address with a scheme, `http` for this machine's names.
+function withScheme(input: string) {
+ const value = input.trim()
+
+ if (!value || /^about:blank$/i.test(value)) return "about:blank"
+
+ if (/^[a-z][a-z\d+.-]*:\/\//i.test(value)) return value
+ const local = /^(?:(?:[a-z\d-]+\.)*localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value)
+
+ return `${local ? "http" : "https"}://${value}`
+}
diff --git a/packages/gui-extensions/src/browser/renderer.tsx b/packages/gui-extensions/src/browser/renderer.tsx
index 70118801c30..0304feecc0e 100644
--- a/packages/gui-extensions/src/browser/renderer.tsx
+++ b/packages/gui-extensions/src/browser/renderer.tsx
@@ -1,9 +1,9 @@
import { createMemo, createSignal, getOwner, lazy, onCleanup, runWithOwner, Show, Suspense } from "solid-js"
-import { Icon } from "@opencode/ui/icon"
import { Command, createKeyed, LinkHandler, MenuItem, onIdle, Panel, Style, type PanelTab, type Setup } from "../sdk"
import { Browser } from "./contract"
import type definition from "./index"
import type { Model } from "./model"
+import { PageIcon } from "./page-icon"
import barStyles from "./bar.css?inline"
import commentStyles from "./comment.css?inline"
import tabStyles from "./tabs.css?inline"
@@ -153,9 +153,7 @@ const setup: Setup = (ctx) => {
},
label: () => (
-
}>
- {(source) =>
})
}
-
+
{text()}
),