Parcourir la source

feat(client): refine Sidebar Browser controls

imccyu il y a 1 jour
Parent
commit
ef1218ac24

+ 16 - 6
apps/web/tests/sidebar-browser.e2e.ts

@@ -39,6 +39,10 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
         body: `<h1>${name}</h1><a href="/inside">Inside navigation</a><p>isolated HTTPS fixture</p>`,
         body: `<h1>${name}</h1><a href="/inside">Inside navigation</a><p>isolated HTTPS fixture</p>`,
       })
       })
     })
     })
+    await page.route('http://127.0.0.1:3080/**', async route => route.fulfill({
+      contentType: 'text/html',
+      body: '<h1>loopback</h1>',
+    }))
     tripwire = watchConsole(page)
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
@@ -49,7 +53,7 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
     await scaffold?.close()
     await scaffold?.close()
   })
   })
 
 
-  it('navigates sandboxed HTTPS with application-known history', async () => {
+  it('navigates sandboxed HTTP(S) with application-known history', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-browser'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-browser'))
     const settled = scaffold.whenTurnSettled()
     const settled = scaffold.whenTurnSettled()
     const composer = page.locator('[data-composer-input]').first()
     const composer = page.locator('[data-composer-input]').first()
@@ -60,17 +64,17 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
     const column = page.locator('[data-rightbar-col]')
     const column = page.locator('[data-rightbar-col]')
     await page.locator('[data-sidebar-right-expand]').click()
     await page.locator('[data-sidebar-right-expand]').click()
     await column.locator('[data-sidebar-right-guide-entry="browser"]').click()
     await column.locator('[data-sidebar-right-guide-entry="browser"]').click()
-    const input = column.getByRole('textbox', { name: 'Enter an HTTPS or local HTTP address' })
+    const input = column.getByRole('textbox', { name: 'Enter an HTTP(S) address' })
     await input.fill('https://browser.test/one')
     await input.fill('https://browser.test/one')
     await input.press('Enter')
     await input.press('Enter')
     const frame = column.locator('[data-sidebar-browser-frame]')
     const frame = column.locator('[data-sidebar-browser-frame]')
     await frame.waitFor({ state: 'visible' })
     await frame.waitFor({ state: 'visible' })
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor()
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor()
     expect(await frame.getAttribute('allow')).toBeNull()
     expect(await frame.getAttribute('allow')).toBeNull()
-    await column.getByRole('button', { name: 'Disable sandbox and allow loopback' }).click()
+    await column.getByRole('button', { name: 'Disable sandbox restrictions' }).click()
     await expect.poll(() => frame.getAttribute('sandbox')).toBeNull()
     await expect.poll(() => frame.getAttribute('sandbox')).toBeNull()
     await column.getByText('Sandbox restrictions are disabled; a page that reaches the DSH origin can access its Web data.', { exact: true }).waitFor()
     await column.getByText('Sandbox restrictions are disabled; a page that reaches the DSH origin can access its Web data.', { exact: true }).waitFor()
-    await column.getByRole('button', { name: 'Restore sandbox and block loopback' }).click()
+    await column.getByRole('button', { name: 'Restore sandbox restrictions' }).click()
     await expect.poll(() => frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox')
     await expect.poll(() => frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox')
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('link', { name: 'Inside navigation' }).click()
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('link', { name: 'Inside navigation' }).click()
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'inside' }).waitFor()
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'inside' }).waitFor()
@@ -88,10 +92,15 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
     await column.getByRole('button', { name: 'Forward', exact: true }).click()
     await column.getByRole('button', { name: 'Forward', exact: true }).click()
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'two' }).waitFor()
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'two' }).waitFor()
 
 
+    await input.fill('http://127.0.0.1:3080/preview')
+    await input.press('Enter')
+    await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'loopback' }).waitFor()
+    expect(await frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox')
+
     await input.fill('file:///work/index.html')
     await input.fill('file:///work/index.html')
     await input.press('Enter')
     await input.press('Enter')
     const blocked = await column.getByRole('alert').innerText()
     const blocked = await column.getByRole('alert').innerText()
-    expect(blocked).toBe('Only HTTPS and loopback HTTP addresses are supported; use Document Preview for local files.')
+    expect(blocked).toBe('Only HTTP and HTTPS addresses are supported; use Document Preview for local files.')
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await mkdir(SNAPSHOT_DIR, { recursive: true })
     await mkdir(SNAPSHOT_DIR, { recursive: true })
@@ -99,9 +108,10 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
       '# Sidebar Browser', '',
       '# Sidebar Browser', '',
       '- HTTPS sandbox: allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox',
       '- HTTPS sandbox: allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox',
       '- Permissions Policy: browser defaults',
       '- Permissions Policy: browser defaults',
-      '- Sandbox toggle: per-tab and temporary; disabling also allows loopback navigation',
+      '- Sandbox toggle: per-tab and temporary',
       '- Unknown navigation: marker shown; Back, Forward, and external-open disabled',
       '- Unknown navigation: marker shown; Back, Forward, and external-open disabled',
       '- HTTPS history: one -> two -> one -> two',
       '- HTTPS history: one -> two -> one -> two',
+      '- Loopback HTTP: loaded under the default sandbox',
       `- Invalid protocol: ${blocked}`,
       `- Invalid protocol: ${blocked}`,
     ].join('\n'), MODE)
     ].join('\n'), MODE)
     await assertFixtureInventory(SNAPSHOT_DIR, ['browser.expected.md'])
     await assertFixtureInventory(SNAPSHOT_DIR, ['browser.expected.md'])

+ 1 - 1
packages/bundle/web-app/cordis.patch.yml

@@ -233,7 +233,7 @@
     - id: ui-sidebar-documentpreview
     - id: ui-sidebar-documentpreview
       name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
       name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
 
 
-    # Sandboxed HTTPS and opt-in loopback Browser tabs.
+    # Sandboxed HTTP(S) Browser tabs, including loopback targets.
     - id: ui-sidebar-browser
     - id: ui-sidebar-browser
       name: '@deepseek-ai/dsh-client-ui-sidebar-browser'
       name: '@deepseek-ai/dsh-client-ui-sidebar-browser'
 
 

+ 4 - 17
packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts

@@ -7,8 +7,7 @@ import type { BrowserFrame, BrowserFrameState } from './BrowserFrame.ts'
 import { BrowserNavigation } from './BrowserNavigation.ts'
 import { BrowserNavigation } from './BrowserNavigation.ts'
 import type { BrowserTabState } from './BrowserNavigation.ts'
 import type { BrowserTabState } from './BrowserNavigation.ts'
 import type { BrowserStore } from './store.ts'
 import type { BrowserStore } from './store.ts'
-import { isLoopbackHostname, parseBrowserAddress } from './url.ts'
-import type { BrowserTarget } from './url.ts'
+import { parseBrowserAddress } from './url.ts'
 
 
 /** Construction dependencies for one tab-scoped Browser controller. */
 /** Construction dependencies for one tab-scoped Browser controller. */
 export interface BrowserControllerOptions {
 export interface BrowserControllerOptions {
@@ -50,11 +49,6 @@ export class BrowserController {
       this.publish()
       this.publish()
       return
       return
     }
     }
-    if (this.loopbackBlocked(parsed.target)) {
-      this.navigation.addressFailed('loopback')
-      this.publish()
-      return
-    }
     const current = BrowserNavigation.current(this.navigation.snapshot)
     const current = BrowserNavigation.current(this.navigation.snapshot)
     if (current?.url === parsed.target.url) {
     if (current?.url === parsed.target.url) {
       this.reload()
       this.reload()
@@ -87,11 +81,6 @@ export class BrowserController {
   private start(request: NonNullable<BrowserTabState['request']>): void {
   private start(request: NonNullable<BrowserTabState['request']>): void {
     this.publish()
     this.publish()
     this.frame.clearDocument()
     this.frame.clearDocument()
-    if (this.loopbackBlocked(request.target)) {
-      this.navigation.requestBlocked(request.revision, 'loopback')
-      this.publish()
-      return
-    }
     this.frame.setDocument({ target: request.target, src: request.target.url, revision: request.revision })
     this.frame.setDocument({ target: request.target, src: request.target.url, revision: request.revision })
   }
   }
 
 
@@ -102,11 +91,6 @@ export class BrowserController {
     if (this.navigation.snapshot !== previous) this.publish()
     if (this.navigation.snapshot !== previous) this.publish()
   }
   }
 
 
-  private loopbackBlocked(target: BrowserTarget): boolean {
-    return this.frame.getSnapshot().sandboxed
-      && isLoopbackHostname(new URL(target.url).hostname)
-  }
-
   private publish(): void {
   private publish(): void {
     this.options.actions.replace(this.options.tabId, this.navigation.snapshot)
     this.options.actions.replace(this.options.tabId, this.navigation.snapshot)
   }
   }
@@ -141,6 +125,8 @@ export interface BrowserInjected {
   toggleSandbox(tabId: TabId): void
   toggleSandbox(tabId: TabId): void
   /** @param tabId - tab occurrence. @param revision - rendered document revision. */
   /** @param tabId - tab occurrence. @param revision - rendered document revision. */
   reportLoaded(tabId: TabId, revision: number): void
   reportLoaded(tabId: TabId, revision: number): void
+  /** @param tabId - tab occurrence. @param revision - rendered document revision that emitted `error`. */
+  reportLoadFailed(tabId: TabId, revision: number): void
 }
 }
 
 
 /**
 /**
@@ -175,5 +161,6 @@ export function createBrowserControllers(
     reload: (tabId) => { controller(tabId)?.reload() },
     reload: (tabId) => { controller(tabId)?.reload() },
     toggleSandbox: (tabId) => { controller(tabId)?.frame.toggleSandbox() },
     toggleSandbox: (tabId) => { controller(tabId)?.frame.toggleSandbox() },
     reportLoaded: (tabId, revision) => { controller(tabId)?.frame.reportLoaded(revision) },
     reportLoaded: (tabId, revision) => { controller(tabId)?.frame.reportLoaded(revision) },
+    reportLoadFailed: (tabId, revision) => { controller(tabId)?.frame.reportLoadFailed(revision) },
   }
   }
 }
 }

+ 17 - 3
packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts

@@ -15,6 +15,7 @@ export interface BrowserDocument {
 export interface BrowserFrameState {
 export interface BrowserFrameState {
   readonly document: BrowserDocument | undefined
   readonly document: BrowserDocument | undefined
   readonly sandboxed: boolean
   readonly sandboxed: boolean
+  readonly loadFailed: boolean
 }
 }
 
 
 /** Browser rendering operations shared by Web and future Electron implementations. */
 /** Browser rendering operations shared by Web and future Electron implementations. */
@@ -27,6 +28,8 @@ export interface BrowserFrame extends HostObservable<BrowserFrameState> {
   clearDocument(): BrowserDocument | undefined
   clearDocument(): BrowserDocument | undefined
   /** @param revision - rendered document revision reported by the carrier. */
   /** @param revision - rendered document revision reported by the carrier. */
   reportLoaded(revision: number): void
   reportLoaded(revision: number): void
+  /** @param revision - rendered document revision whose carrier reported an error. */
+  reportLoadFailed(revision: number): void
 }
 }
 
 
 /** Owns transient iframe and sandbox-toggle state independently from URL navigation. */
 /** Owns transient iframe and sandbox-toggle state independently from URL navigation. */
@@ -34,6 +37,7 @@ export class IframeImpl implements BrowserFrame {
   private readonly store: SnapshotStore<BrowserFrameState> = createSnapshotStore({
   private readonly store: SnapshotStore<BrowserFrameState> = createSnapshotStore({
     document: undefined,
     document: undefined,
     sandboxed: true,
     sandboxed: true,
+    loadFailed: false,
   })
   })
 
 
   /**
   /**
@@ -69,7 +73,7 @@ export class IframeImpl implements BrowserFrame {
    * @internal
    * @internal
    */
    */
   setDocument(document: BrowserDocument): void {
   setDocument(document: BrowserDocument): void {
-    this.store.set({ ...this.store.getSnapshot(), document })
+    this.store.set({ ...this.store.getSnapshot(), document, loadFailed: false })
   }
   }
 
 
   /**
   /**
@@ -78,8 +82,11 @@ export class IframeImpl implements BrowserFrame {
    * @internal
    * @internal
    */
    */
   clearDocument(): BrowserDocument | undefined {
   clearDocument(): BrowserDocument | undefined {
-    const document = this.store.getSnapshot().document
-    if (document !== undefined) this.store.set({ ...this.store.getSnapshot(), document: undefined })
+    const current = this.store.getSnapshot()
+    const { document } = current
+    if (document !== undefined) {
+      this.store.set({ ...current, document: undefined, loadFailed: false })
+    }
     return document
     return document
   }
   }
 
 
@@ -87,4 +94,11 @@ export class IframeImpl implements BrowserFrame {
   reportLoaded(revision: number): void {
   reportLoaded(revision: number): void {
     this.documentLoaded(revision)
     this.documentLoaded(revision)
   }
   }
+
+  /** @param revision - rendered document revision whose iframe emitted `error`. */
+  reportLoadFailed(revision: number): void {
+    const current = this.store.getSnapshot()
+    if (current.document?.revision !== revision || current.loadFailed) return
+    this.store.set({ ...current, loadFailed: true })
+  }
 }
 }

+ 0 - 16
packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts

@@ -13,7 +13,6 @@ export type BrowserNavigationStatus =
   | { readonly status: 'loading'; readonly revision: number }
   | { readonly status: 'loading'; readonly revision: number }
   | { readonly status: 'known'; readonly revision: number }
   | { readonly status: 'known'; readonly revision: number }
   | { readonly status: 'unknown'; readonly revision: number }
   | { readonly status: 'unknown'; readonly revision: number }
-  | { readonly status: 'failed'; readonly revision: number }
 
 
 /** Address-policy or loading failure shown below the toolbar. */
 /** Address-policy or loading failure shown below the toolbar. */
 export type BrowserFailure =
 export type BrowserFailure =
@@ -159,21 +158,6 @@ export class BrowserNavigation {
     }
     }
   }
   }
 
 
-  /**
-   * Refuse a current request after its target becomes disallowed by carrier policy.
-   * @param revision - blocked request revision.
-   * @param reason - address-policy refusal.
-   */
-  requestBlocked(revision: number, reason: BrowserAddressFailure): void {
-    const navigation = this.value.navigation
-    if (navigation.status !== 'loading' || navigation.revision !== revision) return
-    this.value = {
-      ...this.value,
-      navigation: { status: 'failed', revision },
-      failure: { kind: 'address', reason },
-    }
-  }
-
   private request(
   private request(
     target: BrowserTarget,
     target: BrowserTarget,
     basis: BrowserTabState,
     basis: BrowserTabState,

+ 3 - 15
packages/client/ui-sidebar-browser/src/client/browser/url.ts

@@ -1,4 +1,4 @@
-/** Address parsing for the Sidebar browser's HTTPS and loopback HTTP allowlist. */
+/** Address parsing for the Sidebar browser's HTTP(S) allowlist. */
 
 
 /** Maximum accepted address length; this bounds persisted navigation state. */
 /** Maximum accepted address length; this bounds persisted navigation state. */
 export const MAX_BROWSER_URL_LENGTH = 16 * 1024
 export const MAX_BROWSER_URL_LENGTH = 16 * 1024
@@ -9,7 +9,7 @@ export type BrowserTarget =
   | { readonly kind: 'http'; readonly url: string; readonly title: string }
   | { readonly kind: 'http'; readonly url: string; readonly title: string }
 
 
 /** Why an address was refused before navigation. */
 /** Why an address was refused before navigation. */
-export type BrowserAddressFailure = 'empty' | 'invalid' | 'protocol' | 'credentials' | 'application-origin' | 'loopback'
+export type BrowserAddressFailure = 'empty' | 'invalid' | 'protocol' | 'credentials' | 'application-origin'
 
 
 /** Result of parsing an address-bar value. */
 /** Result of parsing an address-bar value. */
 export type BrowserAddressResult =
 export type BrowserAddressResult =
@@ -32,7 +32,7 @@ export function parseBrowserAddress(input: string, applicationOrigin?: string):
   let url: URL
   let url: URL
   try { url = new URL(candidate) } catch { return { ok: false, reason: 'invalid' } }
   try { url = new URL(candidate) } catch { return { ok: false, reason: 'invalid' } }
   if (url.username !== '' || url.password !== '') return { ok: false, reason: 'credentials' }
   if (url.username !== '' || url.password !== '') return { ok: false, reason: 'credentials' }
-  if (url.protocol === 'https:' || (url.protocol === 'http:' && isLoopbackHostname(url.hostname))) {
+  if (url.protocol === 'https:' || url.protocol === 'http:') {
     if (applicationOrigin !== undefined && applicationOrigin !== 'null') {
     if (applicationOrigin !== undefined && applicationOrigin !== 'null') {
       try {
       try {
         if (url.origin === new URL(applicationOrigin).origin) return { ok: false, reason: 'application-origin' }
         if (url.origin === new URL(applicationOrigin).origin) return { ok: false, reason: 'application-origin' }
@@ -44,15 +44,3 @@ export function parseBrowserAddress(input: string, applicationOrigin?: string):
   }
   }
   return { ok: false, reason: 'protocol' }
   return { ok: false, reason: 'protocol' }
 }
 }
-
-/**
- * Test whether a normalized URL host belongs to the local loopback interface.
- * @param hostname - URL hostname with IPv6 brackets retained.
- * @returns whether the hostname is localhost, IPv6 loopback, or IPv4 127/8.
- */
-export function isLoopbackHostname(hostname: string): boolean {
-  if (hostname === 'localhost' || hostname === '[::1]') return true
-  const octets = hostname.split('.')
-  return octets.length === 4 && octets[0] === '127'
-    && octets.every(part => /^\d{1,3}$/u.test(part) && Number(part) <= 255)
-}

+ 1 - 1
packages/client/ui-sidebar-browser/src/client/index.ts

@@ -1,4 +1,4 @@
-/** Register the HTTPS Browser tab type in the right Sidebar. */
+/** Register the HTTP(S) Browser tab type in the right Sidebar. */
 import type { Context } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'

+ 14 - 14
packages/client/ui-sidebar-browser/src/client/locales.ts

@@ -2,25 +2,25 @@
 export const zh = {
 export const zh = {
   'type.label': '浏览器',
   'type.label': '浏览器',
   'guide.title': '浏览器',
   'guide.title': '浏览器',
-  'guide.description': '浏览 HTTPS 网页或 loopback 服务',
-  'address.placeholder': '输入 HTTPS 或本地 HTTP 地址',
+  'guide.description': '浏览 HTTP(S) 网页',
+  'address.placeholder': '输入 HTTP(S) 地址',
   'address.changed': 'URL 已变化',
   'address.changed': 'URL 已变化',
   back: '后退',
   back: '后退',
   forward: '前进',
   forward: '前进',
   reload: '刷新',
   reload: '刷新',
   go: '前往',
   go: '前往',
   external: '在系统浏览器中打开',
   external: '在系统浏览器中打开',
-  'sandbox.disable': '关闭沙箱限制并允许 loopback',
-  'sandbox.enable': '恢复沙箱限制并禁止 loopback',
+  'sandbox.disable': '关闭沙箱限制',
+  'sandbox.enable': '恢复沙箱限制',
   'sandbox.warning': '沙箱限制已关闭;页面若到达 DSH 同源地址,可以访问该 origin 的网页数据。',
   'sandbox.warning': '沙箱限制已关闭;页面若到达 DSH 同源地址,可以访问该 origin 的网页数据。',
-  start: '输入 HTTPS 地址开始浏览',
+  start: '输入 HTTP(S) 地址开始浏览',
   loading: '正在打开…',
   loading: '正在打开…',
   'error.empty': '请输入地址。',
   'error.empty': '请输入地址。',
   'error.invalid': '这个地址无效或过长。',
   'error.invalid': '这个地址无效或过长。',
-  'error.protocol': '只支持 HTTPSloopback HTTP 地址;本地文件请使用文档预览。',
+  'error.protocol': '只支持 HTTP 和 HTTPS 地址;本地文件请使用文档预览。',
   'error.credentials': '地址不能包含用户名或密码。',
   'error.credentials': '地址不能包含用户名或密码。',
   'error.application-origin': '不能在嵌入浏览器中打开 DSH 应用自身。',
   'error.application-origin': '不能在嵌入浏览器中打开 DSH 应用自身。',
-  'error.loopback': '关闭沙箱限制后才能打开 loopback 地址。',
+  'web.loadFailed': '页面报告加载失败或可能禁止嵌入;可尝试在系统浏览器中打开。',
   'web.unknown': '页面已在 iframe 内跳转;Web 模式无法读取当前 URL。',
   'web.unknown': '页面已在 iframe 内跳转;Web 模式无法读取当前 URL。',
 } satisfies Record<string, string>
 } satisfies Record<string, string>
 
 
@@ -31,25 +31,25 @@ export type SidebarBrowserKey = keyof typeof zh
 export const en = {
 export const en = {
   'type.label': 'Browser',
   'type.label': 'Browser',
   'guide.title': 'Browser',
   'guide.title': 'Browser',
-  'guide.description': 'Browse HTTPS pages or loopback services',
-  'address.placeholder': 'Enter an HTTPS or local HTTP address',
+  'guide.description': 'Browse HTTP(S) pages',
+  'address.placeholder': 'Enter an HTTP(S) address',
   'address.changed': 'URL changed',
   'address.changed': 'URL changed',
   back: 'Back',
   back: 'Back',
   forward: 'Forward',
   forward: 'Forward',
   reload: 'Reload',
   reload: 'Reload',
   go: 'Go',
   go: 'Go',
   external: 'Open in system browser',
   external: 'Open in system browser',
-  'sandbox.disable': 'Disable sandbox and allow loopback',
-  'sandbox.enable': 'Restore sandbox and block loopback',
+  'sandbox.disable': 'Disable sandbox restrictions',
+  'sandbox.enable': 'Restore sandbox restrictions',
   'sandbox.warning': 'Sandbox restrictions are disabled; a page that reaches the DSH origin can access its Web data.',
   'sandbox.warning': 'Sandbox restrictions are disabled; a page that reaches the DSH origin can access its Web data.',
-  start: 'Enter an HTTPS address to start browsing',
+  start: 'Enter an HTTP(S) address to start browsing',
   loading: 'Opening…',
   loading: 'Opening…',
   'error.empty': 'Enter an address.',
   'error.empty': 'Enter an address.',
   'error.invalid': 'That address is invalid or too long.',
   'error.invalid': 'That address is invalid or too long.',
-  'error.protocol': 'Only HTTPS and loopback HTTP addresses are supported; use Document Preview for local files.',
+  'error.protocol': 'Only HTTP and HTTPS addresses are supported; use Document Preview for local files.',
   'error.credentials': 'Addresses cannot contain a username or password.',
   'error.credentials': 'Addresses cannot contain a username or password.',
   'error.application-origin': 'The embedded browser cannot open the DSH application itself.',
   'error.application-origin': 'The embedded browser cannot open the DSH application itself.',
-  'error.loopback': 'Disable sandbox restrictions before opening a loopback address.',
+  'web.loadFailed': 'The page reported a load failure or may block embedding; try opening it in the system browser.',
   'web.unknown': 'The page navigated inside the iframe; Web mode cannot read its current URL.',
   'web.unknown': 'The page navigated inside the iframe; Web mode cannot read its current URL.',
 } satisfies Record<SidebarBrowserKey, string>
 } satisfies Record<SidebarBrowserKey, string>
 
 

+ 21 - 8
packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx

@@ -7,7 +7,8 @@ import {
   IconLinkOutline14,
   IconLinkOutline14,
   IconRefreshOutline14,
   IconRefreshOutline14,
   IconRightUpOutline16,
   IconRightUpOutline16,
-  IconShieldOutline16,
+  SHIELD_OUTLINE_PATH,
+  SHIELD_OUTLINE_STROKE,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 import type { BrowserInjected } from '../browser/BrowserController.ts'
 import type { BrowserInjected } from '../browser/BrowserController.ts'
@@ -20,7 +21,18 @@ import css from './Browser.module.css'
 /** Fixed Web iframe sandbox; popups escape the sandbox while top navigation remains absent. */
 /** Fixed Web iframe sandbox; popups escape the sandbox while top navigation remains absent. */
 export const WEB_BROWSER_SANDBOX = 'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox'
 export const WEB_BROWSER_SANDBOX = 'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox'
 
 
-const INITIAL_BROWSER_FRAME: BrowserFrameState = { document: undefined, sandboxed: true }
+const INITIAL_BROWSER_FRAME: BrowserFrameState = { document: undefined, sandboxed: true, loadFailed: false }
+
+function SandboxPolicyIcon({ sandboxed }: { readonly sandboxed: boolean }): ReactNode {
+  return (
+    <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden>
+      <path d={SHIELD_OUTLINE_PATH} stroke="currentColor" strokeWidth={SHIELD_OUTLINE_STROKE} strokeLinejoin="round" />
+      {sandboxed
+        ? <path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
+        : <path d="M10.6074 4.40278L8.00975 6.99973L10.6074 9.59739L9.59736 10.6074L6.9997 8.00978L4.40274 10.6074L3.3927 9.59739L5.98966 6.99973L3.3927 4.40278L4.40274 3.39273L6.9997 5.98969L9.59736 3.39273L10.6074 4.40278Z" fill="currentColor" transform="translate(1.2 0.8)" />}
+    </svg>
+  )
+}
 
 
 /** Browser body props assembled by the tab seat. */
 /** Browser body props assembled by the tab seat. */
 export type BrowserBodyProps = PropsRuntime<'sidebar.right.pane.tab'>
 export type BrowserBodyProps = PropsRuntime<'sidebar.right.pane.tab'>
@@ -45,7 +57,7 @@ function useBrowserDraft(
 /** Browser tab renderer for a controller-owned URL state and Web iframe carrier. */
 /** Browser tab renderer for a controller-owned URL state and Web iframe carrier. */
 export function BrowserBody(props: BrowserBodyProps): ReactNode {
 export function BrowserBody(props: BrowserBodyProps): ReactNode {
   const {
   const {
-    goBack, goForward, loadUrl, mount, reload, reportLoaded, toggleSandbox,
+    goBack, goForward, loadUrl, mount, reload, reportLoaded, reportLoadFailed, toggleSandbox,
     useBrowserFrame, useStore, useTabInfo, t,
     useBrowserFrame, useStore, useTabInfo, t,
   } = props
   } = props
   const { tab } = useTabInfo()
   const { tab } = useTabInfo()
@@ -73,15 +85,13 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
   }, [loadUrl, mountCount, reload, tab.id])
   }, [loadUrl, mountCount, reload, tab.id])
 
 
   const frameState = useBrowserFrame(tab.id) ?? INITIAL_BROWSER_FRAME
   const frameState = useBrowserFrame(tab.id) ?? INITIAL_BROWSER_FRAME
-  const { document, sandboxed } = frameState
+  const { document, sandboxed, loadFailed } = frameState
 
 
   const navigationUnknown = state.navigation.status === 'unknown'
   const navigationUnknown = state.navigation.status === 'unknown'
   const externalUrl = navigationUnknown ? undefined : current?.url
   const externalUrl = navigationUnknown ? undefined : current?.url
   const submit = (event: FormEvent): void => { event.preventDefault(); loadUrl(tab.id, draft) }
   const submit = (event: FormEvent): void => { event.preventDefault(); loadUrl(tab.id, draft) }
   const failure = state.failure === undefined ? undefined : failureText(state.failure.reason, t)
   const failure = state.failure === undefined ? undefined : failureText(state.failure.reason, t)
-  const placeholder = current === undefined
-    ? t('start')
-    : state.navigation.status === 'failed' ? undefined : t('loading')
+  const placeholder = current === undefined ? t('start') : t('loading')
 
 
   return (
   return (
     <div className={css.root}>
     <div className={css.root}>
@@ -120,9 +130,10 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
           aria-pressed={!sandboxed}
           aria-pressed={!sandboxed}
           disabled={mountCount === 0}
           disabled={mountCount === 0}
           onClick={() => { toggleSandbox(tab.id) }}
           onClick={() => { toggleSandbox(tab.id) }}
-        ><IconShieldOutline16 size={15} /></button>
+        ><SandboxPolicyIcon sandboxed={sandboxed} /></button>
       </form>
       </form>
       {!sandboxed && <div className={css.sandboxWarning} role="status">{t('sandbox.warning')}</div>}
       {!sandboxed && <div className={css.sandboxWarning} role="status">{t('sandbox.warning')}</div>}
+      {loadFailed && <div className={css.failure} role="status">{t('web.loadFailed')}</div>}
       {failure !== undefined && <div className={css.failure} role="alert">{failure}</div>}
       {failure !== undefined && <div className={css.failure} role="alert">{failure}</div>}
       {document === undefined
       {document === undefined
         ? <div className={css.start}>{placeholder}</div>
         ? <div className={css.start}>{placeholder}</div>
@@ -134,6 +145,8 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
           referrerPolicy="no-referrer"
           referrerPolicy="no-referrer"
           title={document.target.title}
           title={document.target.title}
           onLoad={() => { reportLoaded(tab.id, document.revision) }}
           onLoad={() => { reportLoaded(tab.id, document.revision) }}
+          /* v8 ignore next -- jsdom does not dispatch React iframe error events; BrowserFrame owns the tested behavior. */
+          onError={() => { reportLoadFailed(tab.id, document.revision) }}
           data-sidebar-browser-frame
           data-sidebar-browser-frame
         />}
         />}
       {navigationUnknown && <p className={css.limit}>{t('web.unknown')}</p>}
       {navigationUnknown && <p className={css.limit}>{t('web.unknown')}</p>}

+ 30 - 7
packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { useSyncExternalStore } from 'react'
 import { useSyncExternalStore } from 'react'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
@@ -64,7 +64,7 @@ function mountBrowser(navigation?: { readonly url?: string }) {
   } as unknown as BrowserBodyProps
   } as unknown as BrowserBodyProps
   const renderBody = () => render(<BrowserBody {...props} />)
   const renderBody = () => render(<BrowserBody {...props} />)
   return {
   return {
-    view: renderBody(), remount: renderBody, store, lifetime,
+    view: renderBody(), remount: renderBody, store, lifetime, injected,
   }
   }
 }
 }
 
 
@@ -103,11 +103,15 @@ describe('BrowserBody', () => {
     expect(frame.getAttribute('allow')).toBeNull()
     expect(frame.getAttribute('allow')).toBeNull()
     expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer')
     expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer')
     const disableSandbox = mounted.view.getByRole('button', { name: zh['sandbox.disable'] })
     const disableSandbox = mounted.view.getByRole('button', { name: zh['sandbox.disable'] })
+    const protectedMark = disableSandbox.querySelector('svg path:last-child')?.getAttribute('d')
     fireEvent.click(disableSandbox)
     fireEvent.click(disableSandbox)
     await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBeNull() })
     await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBeNull() })
     expect(mounted.view.getByRole('status').textContent).toBe(zh['sandbox.warning'])
     expect(mounted.view.getByRole('status').textContent).toBe(zh['sandbox.warning'])
-    fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.enable'] }))
+    const enableSandbox = mounted.view.getByRole('button', { name: zh['sandbox.enable'] })
+    expect(enableSandbox.querySelector('svg path:last-child')?.getAttribute('d')).not.toBe(protectedMark)
+    fireEvent.click(enableSandbox)
     await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX) })
     await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX) })
+    expect(mounted.view.getByRole('button', { name: zh['sandbox.disable'] }).querySelector('svg path:last-child')?.getAttribute('d')).toBe(protectedMark)
 
 
     fireEvent.change(input, { target: { value: 'https://example.com/two' } })
     fireEvent.change(input, { target: { value: 'https://example.com/two' } })
     fireEvent.submit(input.closest('form')!)
     fireEvent.submit(input.closest('form')!)
@@ -159,17 +163,36 @@ describe('BrowserBody', () => {
     expect(mounted.store.getSnapshot().byTab[TAB]?.navigation).toEqual({ status: 'loading', revision: revision + 1 })
     expect(mounted.store.getSnapshot().byTab[TAB]?.navigation).toEqual({ status: 'loading', revision: revision + 1 })
   })
   })
 
 
-  it('shows a loopback policy failure without a loading placeholder', async () => {
+  it('shows a best-effort iframe error notice until the next controlled load', async () => {
+    const mounted = mountBrowser()
+    const input = mounted.view.getByRole('textbox')
+    fireEvent.change(input, { target: { value: 'https://example.com/one' } })
+    fireEvent.submit(input.closest('form')!)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')).not.toBeNull() })
+    const failedRevision = mounted.store.getSnapshot().byTab[TAB]!.request!.revision
+
+    act(() => { mounted.injected.reportLoadFailed(TAB, failedRevision) })
+    expect(mounted.injected.keyedHooks.browserFrame(TAB)?.getSnapshot().loadFailed).toBe(true)
+    await waitFor(() => { expect(mounted.view.getByText(zh['web.loadFailed'])).toBeDefined() })
+    fireEvent.click(mounted.view.getByRole('button', { name: zh.reload }))
+    await waitFor(() => { expect(mounted.view.queryByText(zh['web.loadFailed'])).toBeNull() })
+    act(() => { mounted.injected.reportLoadFailed(TAB, failedRevision) })
+    expect(mounted.view.queryByText(zh['web.loadFailed'])).toBeNull()
+  })
+
+  it('loads loopback under the default sandbox and keeps it across sandbox changes', async () => {
     const mounted = mountBrowser()
     const mounted = mountBrowser()
     const input = mounted.view.getByRole('textbox')
     const input = mounted.view.getByRole('textbox')
-    fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.disable'] }))
     fireEvent.change(input, { target: { value: 'http://localhost:5173/' } })
     fireEvent.change(input, { target: { value: 'http://localhost:5173/' } })
     fireEvent.submit(input.closest('form')!)
     fireEvent.submit(input.closest('form')!)
     await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('http://localhost:5173/') })
     await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('http://localhost:5173/') })
+    expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX)
 
 
+    fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.disable'] }))
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBeNull() })
     fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.enable'] }))
     fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.enable'] }))
-    expect(mounted.view.getByRole('alert').textContent).toBe(zh['error.loopback'])
-    expect(mounted.view.queryByText(zh.loading)).toBeNull()
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX) })
+    expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('http://localhost:5173/')
   })
   })
 
 
   it('opens known Web targets externally and consumes an initial typed navigation', async () => {
   it('opens known Web targets externally and consumes an initial typed navigation', async () => {

+ 23 - 8
packages/client/ui-sidebar-browser/tests/browser-controller.client.spec.ts

@@ -100,7 +100,7 @@ describe('BrowserController', () => {
     expect(face.keyedHooks.browserFrame(TAB)).toBe(replacement)
     expect(face.keyedHooks.browserFrame(TAB)).toBe(replacement)
   })
   })
 
 
-  it('allows loopback only while this tab has disabled its sandbox', () => {
+  it('loads loopback under sandbox and reloads it across sandbox changes', () => {
     const store = createBrowserStore().create('browser-controller-loopback-test')
     const store = createBrowserStore().create('browser-controller-loopback-test')
     const face = createBrowserControllers(store.actions)
     const face = createBrowserControllers(store.actions)
     const tabLifetime = lifetime()
     const tabLifetime = lifetime()
@@ -108,20 +108,35 @@ describe('BrowserController', () => {
     const frame = face.keyedHooks.browserFrame(TAB)!
     const frame = face.keyedHooks.browserFrame(TAB)!
 
 
     face.loadUrl(TAB, 'http://localhost:5173/app')
     face.loadUrl(TAB, 'http://localhost:5173/app')
-    expect(store.getSnapshot().byTab[TAB]).toMatchObject({
-      navigation: { status: 'empty' }, failure: { kind: 'address', reason: 'loopback' },
+    expect(frame.getSnapshot()).toMatchObject({
+      sandboxed: true,
+      document: { src: 'http://localhost:5173/app' },
     })
     })
-    expect(frame.getSnapshot().document).toBeUndefined()
+    const beforeToggle = store.getSnapshot().byTab[TAB]!.request!.revision
 
 
     face.toggleSandbox(TAB)
     face.toggleSandbox(TAB)
     expect(frame.getSnapshot().sandboxed).toBe(false)
     expect(frame.getSnapshot().sandboxed).toBe(false)
-    face.loadUrl(TAB, 'http://localhost:5173/app')
     expect(frame.getSnapshot().document?.src).toBe('http://localhost:5173/app')
     expect(frame.getSnapshot().document?.src).toBe('http://localhost:5173/app')
+    expect(store.getSnapshot().byTab[TAB]?.request?.revision).toBe(beforeToggle + 1)
 
 
     face.toggleSandbox(TAB)
     face.toggleSandbox(TAB)
-    expect(frame.getSnapshot()).toEqual({ document: undefined, sandboxed: true })
-    expect(store.getSnapshot().byTab[TAB]).toMatchObject({
-      navigation: { status: 'failed' }, failure: { kind: 'address', reason: 'loopback' },
+    expect(frame.getSnapshot()).toMatchObject({
+      sandboxed: true,
+      document: { src: 'http://localhost:5173/app' },
+    })
+    expect(store.getSnapshot().byTab[TAB]?.request?.revision).toBe(beforeToggle + 2)
+  })
+
+  it('loads public HTTP without changing the sandbox policy', () => {
+    const store = createBrowserStore().create('browser-controller-http-test')
+    const face = createBrowserControllers(store.actions)
+    const tabLifetime = lifetime()
+    face.mount(TAB, tabLifetime.signal, APP)
+
+    face.loadUrl(TAB, 'http://example.test/path')
+    expect(face.keyedHooks.browserFrame(TAB)?.getSnapshot()).toMatchObject({
+      sandboxed: true,
+      document: { src: 'http://example.test/path' },
     })
     })
   })
   })
 })
 })

+ 12 - 1
packages/client/ui-sidebar-browser/tests/browser-frame.client.spec.ts

@@ -18,10 +18,21 @@ describe('IframeImpl', () => {
       src: 'https://example.test/',
       src: 'https://example.test/',
       revision: 4,
       revision: 4,
     }
     }
+    frame.reportLoadFailed(4)
+    expect(frame.getSnapshot().loadFailed).toBe(false)
     frame.setDocument(document)
     frame.setDocument(document)
+    frame.reportLoadFailed(3)
+    expect(frame.getSnapshot().loadFailed).toBe(false)
+    frame.reportLoadFailed(4)
+    frame.reportLoadFailed(4)
+    expect(frame.getSnapshot().loadFailed).toBe(true)
     frame.reportLoaded(4)
     frame.reportLoaded(4)
     expect(loaded).toHaveBeenCalledWith(4)
     expect(loaded).toHaveBeenCalledWith(4)
-    expect(frame.clearDocument()).toBe(document)
+    frame.setDocument({ ...document, revision: 5 })
+    expect(frame.getSnapshot().loadFailed).toBe(false)
+    expect(frame.clearDocument()?.revision).toBe(5)
+    frame.reportLoadFailed(5)
+    expect(frame.getSnapshot().loadFailed).toBe(false)
     expect(frame.clearDocument()).toBeUndefined()
     expect(frame.clearDocument()).toBeUndefined()
     expect(listener).toHaveBeenCalled()
     expect(listener).toHaveBeenCalled()
     unsubscribe()
     unsubscribe()

+ 2 - 11
packages/client/ui-sidebar-browser/tests/browser-navigation.client.spec.ts

@@ -51,7 +51,7 @@ describe('BrowserNavigation', () => {
     expect(navigation.snapshot.navigation).toEqual({ status: 'loading', revision: reload.revision })
     expect(navigation.snapshot.navigation).toEqual({ status: 'loading', revision: reload.revision })
   })
   })
 
 
-  it('keeps blocked requests revision-scoped and leaves address failures outside navigation', () => {
+  it('leaves address failures outside navigation', () => {
     const navigation = new BrowserNavigation()
     const navigation = new BrowserNavigation()
     navigation.frameLoaded(1)
     navigation.frameLoaded(1)
     navigation.navigate(httpsTarget(1))
     navigation.navigate(httpsTarget(1))
@@ -60,18 +60,9 @@ describe('BrowserNavigation', () => {
     navigation.frameLoaded(revision)
     navigation.frameLoaded(revision)
     expect(navigation.snapshot.navigation).toEqual({ status: 'known', revision })
     expect(navigation.snapshot.navigation).toEqual({ status: 'known', revision })
 
 
-    navigation.reload()
-    const reloadRevision = navigation.snapshot.request!.revision
-    navigation.requestBlocked(reloadRevision - 1, 'loopback')
-    expect(navigation.snapshot.navigation).toEqual({ status: 'loading', revision: reloadRevision })
-    navigation.requestBlocked(reloadRevision, 'loopback')
-    expect(navigation.snapshot).toMatchObject({
-      navigation: { status: 'failed', revision: reloadRevision },
-      failure: { kind: 'address', reason: 'loopback' },
-    })
     navigation.addressFailed('invalid')
     navigation.addressFailed('invalid')
     expect(navigation.snapshot).toMatchObject({
     expect(navigation.snapshot).toMatchObject({
-      navigation: { status: 'failed', revision: reloadRevision },
+      navigation: { status: 'known', revision },
       failure: { kind: 'address', reason: 'invalid' },
       failure: { kind: 'address', reason: 'invalid' },
     })
     })
   })
   })

+ 5 - 3
packages/client/ui-sidebar-browser/tests/url.client.spec.ts

@@ -16,14 +16,16 @@ describe('Browser address policy', () => {
     })
     })
   })
   })
 
 
-  it('allows HTTP only on explicit loopback hosts', () => {
+  it('accepts HTTP including loopback hosts', () => {
     expect(parseBrowserAddress('http://localhost:5173/app', APP)).toEqual({
     expect(parseBrowserAddress('http://localhost:5173/app', APP)).toEqual({
       ok: true, target: { kind: 'http', url: 'http://localhost:5173/app', title: 'localhost' },
       ok: true, target: { kind: 'http', url: 'http://localhost:5173/app', title: 'localhost' },
     })
     })
     expect(parseBrowserAddress('http://127.42.0.9/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } })
     expect(parseBrowserAddress('http://127.42.0.9/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } })
     expect(parseBrowserAddress('http://[::1]:8080/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } })
     expect(parseBrowserAddress('http://[::1]:8080/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } })
-    expect(parseBrowserAddress('http://example.com/', APP)).toEqual({ ok: false, reason: 'protocol' })
-    expect(parseBrowserAddress('http://128.0.0.1/', APP)).toEqual({ ok: false, reason: 'protocol' })
+    expect(parseBrowserAddress('http://example.com/', APP)).toEqual({
+      ok: true, target: { kind: 'http', url: 'http://example.com/', title: 'example.com' },
+    })
+    expect(parseBrowserAddress('http://128.0.0.1/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } })
   })
   })
 
 
   it('rejects every undeclared or privileged form', () => {
   it('rejects every undeclared or privileged form', () => {

+ 3 - 2
snapshots/web/sidebar-browser/browser.expected.md

@@ -2,7 +2,8 @@
 
 
 - HTTPS sandbox: allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox
 - HTTPS sandbox: allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox
 - Permissions Policy: browser defaults
 - Permissions Policy: browser defaults
-- Sandbox toggle: per-tab and temporary; disabling also allows loopback navigation
+- Sandbox toggle: per-tab and temporary
 - Unknown navigation: marker shown; Back, Forward, and external-open disabled
 - Unknown navigation: marker shown; Back, Forward, and external-open disabled
 - HTTPS history: one -> two -> one -> two
 - HTTPS history: one -> two -> one -> two
-- Invalid protocol: Only HTTPS and loopback HTTP addresses are supported; use Document Preview for local files.
+- Loopback HTTP: loaded under the default sandbox
+- Invalid protocol: Only HTTP and HTTPS addresses are supported; use Document Preview for local files.