Branch for hardware verification on a host that meets v0.24.1's peer range.
Base: upstream `dsh-better-sidebar` v0.24.1 (tag v0.24.1 = a2751cf), MIT.
Measured delta carried over from the v0.19.1 line: 78 rename-only files, 11 real
edits, 10 additions. Ported with the same measurement method as the v0.19.1
re-base: build a normalized base (upstream tree + the fork rename, excluding
README.md / package.json / docs/plans/* which deliberately keep the upstream
name), extract the delta against it, then three-way apply onto the renamed
v0.24.1 tree (`git apply -3`). 13 conflicts resolved by hand.
Identity:
- version 1.3.0, SIDEBAR_SERVICE_VERSION 1.3.0 (package.json ↔ service.ts pair
is asserted by tests/service.spec.ts), forkedFrom -> v0.24.1/a2751cf.
- package.json keeps the union of both sides' metadata: upstream's new
`zustand` + `icon`/`locale` files entries, plus the fork's `scripts/switch.sh`
entry and its `forkedFrom` block.
- README.md takes upstream v0.24.1 wholesale (its own v0.24.1 changelog and all
16 sections) and keeps the fork's two deltas: the fork-notice block at the top
(now documenting the 0.2.0-rc.1+ requirement) and the fork package name.
- peerDependencies stay upstream's `^0.2.0-rc.1` — this line does NOT load on
DSH 0.1.x: the host's semver pre-check rejects a caret range across a minor
and silently disables the row (no error, no load).
Carried features, all re-verified against the new base:
- apes:* information-layer tabs (src/client/apes/) — the subtree's session feed
changed shape in 0.1.7 and the port follows the host's own migration notes in
context-types.ts: the per-session catalog is now
`projectionsBySession[id].values.subagentCatalog` (a flat entry list) instead
of 0.1.6's `subagentsByParent` catalog with `entries`/`kind`/`activity`, and
the removed `list.current` is answered by `props.scope.sessionId`. Rows are
normalized to { id, label, mode } with activity read from the durable
summaries, so catalog and byId sources render alike.
- HTML preview delivery through srcdoc (sandbox kept intact).
- Panel host mounted inside the shell's framed content viewport.
Gate: typecheck clean; build clean; 1515 passed / 9 skipped (141 files).
NOT applied to ~/.dsh/profiles/desktop — that profile runs DSH 2.0.10 with
@deepseek-ai/dsh 0.1.5-rc.2, where this line is silently disabled by design.
164 lines
7.5 KiB
TypeScript
164 lines
7.5 KiB
TypeScript
/**
|
|
* Sandbox-contract tests for the two built-in web surfaces (HTML preview
|
|
* iframe and the browser tab iframe). The iframe sandbox — opaque origin,
|
|
* no allow-same-origin, no top-navigation — is the PRIMARY security
|
|
* boundary of both features; these tests pin the exact attribute so a
|
|
* refactor cannot silently widen it. The side card settings can drop the
|
|
* sandbox per-feature (warned); those paths render the warning bar and no
|
|
* sandbox attribute.
|
|
*/
|
|
import { describe, expect, it, beforeEach } from 'vitest'
|
|
import { renderToString } from 'react-dom/server'
|
|
import { createElement } from 'react'
|
|
import './browser-globals.ts'
|
|
import type { Context } from '../src/context-types.ts'
|
|
import { TextEditor } from '../src/client/TextEditor.tsx'
|
|
import { HTML_IFRAME_SANDBOX } from '../src/client/html-preview.ts'
|
|
import { DiffPane, HtmlRenderPreview } from '../src/client/changes/DiffPane.tsx'
|
|
import { createSidebarStore } from '../src/client/state.ts'
|
|
import type { FileViewerProps } from '../src/client/service.ts'
|
|
|
|
const CTX = {} as Context
|
|
|
|
// The copy assertions below pin the zh strings: force the zh locale (the
|
|
// test environment's navigator may be the real Node one with an en locale).
|
|
beforeEach(() => {
|
|
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
|
|
})
|
|
|
|
function viewerProps(store: ReturnType<typeof createSidebarStore>, overrides: Partial<FileViewerProps> = {}): FileViewerProps {
|
|
return {
|
|
ctx: CTX,
|
|
store,
|
|
scope: { sessionId: 's1', cwd: '/p' },
|
|
path: '/p/a/index.html',
|
|
title: 'index.html',
|
|
viewerId: 'html',
|
|
content: '<h1>hi</h1>',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('HTML preview iframe sandbox', () => {
|
|
it('renders the preview iframe with the exact sandbox tokens and no same-origin / top-navigation', () => {
|
|
const store = createSidebarStore()
|
|
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
|
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
|
expect(iframe).toBeDefined()
|
|
// The sandbox tokens are exactly the exported constant...
|
|
expect(iframe).toContain(`sandbox="${HTML_IFRAME_SANDBOX}"`)
|
|
// ...which must never contain the dangerous tokens.
|
|
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
|
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
|
// Opaque-origin framing by construction: the document arrives through
|
|
// srcdoc (fetched by the MAIN frame, the only one the host trusts with
|
|
// the /sidebar route on DSH Desktop) and the frame is never pointed at
|
|
// the route itself. Empty until that fetch resolves. (The server
|
|
// renderer passes srcDoc through camelCase, like referrerPolicy below.)
|
|
expect(iframe).toContain('srcDoc=""')
|
|
expect(iframe).not.toContain('src=')
|
|
// Referrer + permissions policy stay locked even when sandboxed.
|
|
// (React SSR renders the referrerPolicy prop camelCase as written.)
|
|
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
|
expect(iframe).toContain('allow=""')
|
|
})
|
|
|
|
it('renders the live sandbox status row (green on + temporary unlock action)', () => {
|
|
const store = createSidebarStore()
|
|
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
|
// Sandbox ON: the green status + the one-tap temporary unlock button.
|
|
expect(html).toContain('沙箱模式:已启用')
|
|
expect(html).toContain('临时解锁(不安全)')
|
|
// No restore action while the sandbox is on.
|
|
expect(html).not.toContain('恢复沙箱')
|
|
})
|
|
|
|
it('drops the sandbox attribute with the red warning when the setting is on (no restore action — the global setting owns it)', () => {
|
|
const store = createSidebarStore()
|
|
store.setPrefs({ ...store.getPrefs(), htmlViewerNoSandbox: true })
|
|
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
|
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
|
expect(iframe).toBeDefined()
|
|
expect(iframe).not.toContain('sandbox=')
|
|
// The red persistent warning copy is rendered; the temporary-unlock
|
|
// action is NOT offered (re-enabling is the settings page's job).
|
|
expect(html).toContain('沙箱已关闭')
|
|
expect(html).not.toContain('临时解锁(不安全)')
|
|
expect(html).not.toContain('恢复沙箱')
|
|
})
|
|
|
|
it('starts unsandboxed (red, restorable) when the default-unsafe pref is on', () => {
|
|
const store = createSidebarStore()
|
|
store.setPrefs({ ...store.getPrefs(), htmlViewerDefaultUnsafe: true })
|
|
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
|
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
|
expect(iframe).toBeDefined()
|
|
expect(iframe).not.toContain('sandbox=')
|
|
// The red warning + the one-tap restore (this is the LOCAL state).
|
|
expect(html).toContain('沙箱已关闭')
|
|
expect(html).toContain('恢复沙箱')
|
|
expect(html).not.toContain('临时解锁(不安全)')
|
|
})
|
|
|
|
it('markdown preview keeps rendering markdown, not an iframe', () => {
|
|
const store = createSidebarStore()
|
|
const html = renderToString(createElement(TextEditor, viewerProps(store, {
|
|
viewerId: 'markdown',
|
|
path: '/p/readme.md',
|
|
content: '# hi',
|
|
})))
|
|
expect(html).not.toContain('<iframe')
|
|
expect(html).not.toContain('/sidebar/html/')
|
|
// The markdown is rendered into markup, not framed.
|
|
expect(html).toContain('<h1')
|
|
})
|
|
})
|
|
|
|
describe('changes tab HTML render preview sandbox', () => {
|
|
function opProps(path: string) {
|
|
return {
|
|
target: {
|
|
kind: 'op' as const,
|
|
path,
|
|
op: { callId: 'c1', kind: 'read' as const, path, time: 0, running: false, isError: false, read: '<content>1: <html><body>hi</body></html></content>' },
|
|
},
|
|
scope: { sessionId: 's1', cwd: '/p' },
|
|
height: 300,
|
|
onHeightCommit: () => {},
|
|
onClose: () => {},
|
|
onExpand: () => {},
|
|
}
|
|
}
|
|
|
|
it('render iframe is ALWAYS sandboxed (no escape hatch) and delivered through srcdoc', () => {
|
|
const html = renderToString(createElement(HtmlRenderPreview, { src: '/sidebar/html/s1/p/a/index.html', title: '/p/a/index.html' }))
|
|
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
|
expect(iframe).toBeDefined()
|
|
// The sandbox tokens are exactly the shared constant — and unlike the
|
|
// editor viewer this surface has NO no-sandbox escape hatch.
|
|
expect(iframe).toContain(`sandbox="${HTML_IFRAME_SANDBOX}"`)
|
|
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
|
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
|
// Same delivery path as the editor viewer: main-frame fetch -> srcdoc,
|
|
// never a route src the opaque frame could not fetch itself.
|
|
expect(iframe).toContain('srcDoc=""')
|
|
expect(iframe).not.toContain('src=')
|
|
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
|
expect(iframe).toContain('allow=""')
|
|
})
|
|
|
|
it('html op targets show the render toggle (off by default, no iframe); non-html and error targets show none', () => {
|
|
const htmlOpHtml = renderToString(createElement(DiffPane, opProps('/p/a/index.html')))
|
|
expect(htmlOpHtml).toContain('渲染')
|
|
expect(htmlOpHtml).not.toContain('<iframe')
|
|
const md = renderToString(createElement(DiffPane, opProps('/p/a/readme.md')))
|
|
expect(md).not.toContain('渲染')
|
|
const errorProps = opProps('/p/a/index.html')
|
|
const html = renderToString(createElement(DiffPane, {
|
|
...errorProps,
|
|
target: { ...errorProps.target, op: { ...errorProps.target.op, isError: true, errorText: 'boom' } },
|
|
}))
|
|
expect(html).not.toContain('渲染')
|
|
})
|
|
})
|