feat(v0.5.0): secure project previews and fix host UI compatibility

This commit is contained in:
Aisland
2026-10-27 23:09:34 +08:00
parent 6cb86b6c73
commit 56ffe0cd1c
30 changed files with 3202 additions and 1447 deletions
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react'
import { hostPreviewUrl } from './hostTransport'
const SANDBOX = 'allow-scripts allow-forms allow-downloads'
const SITE = '/api/worktable/site/'
/** Accept navigations, not executable URL schemes. Keep saved URLs free of capabilities. */
export function previewTarget(input: string, base: string) {
const url = new URL(input, base)
const own = new URL(base)
const sameHost = url.protocol === own.protocol && url.host === own.host
if (!['http:', 'https:'].includes(url.protocol) && !(sameHost && url.protocol === 'dsh-app:')) throw new Error('Unsupported preview URL')
if (!sameHost || !url.pathname.startsWith(SITE)) return { url: url.href }
const segments = url.pathname.slice(SITE.length).split('/')
const root = decodeURIComponent(segments.shift() ?? '')
if (!root) throw new Error('Missing preview directory')
const relative = segments.map(decodeURIComponent)
if (relative.some(s => s === '..' || /[\\/\0]/.test(s))) throw new Error('Invalid preview path')
return { root, relative: relative.map(encodeURIComponent).join('/'), suffix: url.search + url.hash }
}
/** Untrusted project pages never share the host's document origin or storage. */
export function SandboxedFrame(props: { url: string; title?: string; reloadKey: number }) {
const [src, setSrc] = useState('about:blank')
const [error, setError] = useState('')
useEffect(() => {
let alive = true
let token: string | undefined
const revoke = (value: string) => { void fetch('/api/worktable/preview', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ revoke: value }), keepalive: true,
}).catch(() => {}) }
setSrc('about:blank')
setError('')
void (async () => {
if (!props.url || props.url === 'about:blank') return
const target = previewTarget(props.url, location.href)
if ('url' in target) { if (alive) setSrc(target.url!); return }
const response = await fetch('/api/worktable/preview', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ root: target.root }),
})
if (!response.ok) throw new Error('Preview HTTP ' + response.status)
const grant = await response.json()
if (typeof grant.token !== 'string' || !/^[\w-]{32}$/.test(grant.token) || grant.base !== '/api/worktable/preview-static/' + grant.token + '/') throw new Error('Invalid preview grant')
token = grant.token
if (!alive) { revoke(token!); token = undefined; return }
setSrc(hostPreviewUrl(grant.base + target.relative + target.suffix))
})().catch(err => {
if (token) { revoke(token); token = undefined }
if (alive) setError(String(err))
})
return () => { alive = false; if (token) revoke(token) }
}, [props.url, props.reloadKey])
if (error) return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{error}</span></div>
return <iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src}
data-source-url={props.url} title={props.title ?? ''} sandbox={SANDBOX} referrerPolicy="no-referrer" />
}
+25 -3
View File
@@ -1,6 +1,28 @@
/** 更新公告正文(v0.4.0:双端适配、分组与项目内容隔离;纯文本呈现,发布状态以 GitHub 为准) */
export const CHANGELOG_VERSION = 'v0.4.0'
export const CHANGELOG_V030 = `更新公告 · v0.4.0
/** 更新公告正文;公开发布状态以 GitHub Release 为准。 */
export const CHANGELOG_VERSION = 'v0.5.0'
export const CHANGELOG_V030 = `更新公告 · v0.5.0
🛡️ 新版宿主兼容、安全隔离与侧栏修复
【接口身份校验】文件、目录、写入、Git、工作区接口和终端握手复用宿主身份验证。新版只允许 owner,0.2.0 使用单 operator 身份;不能确认身份时拒绝访问,不静默开放。健康接口仍可读状态,不代表全部功能可用。
【项目网页隔离】浏览器、动画及自定义 HTML 窗格不再拥有 DSH 主界面的同源权限。本地 HTML 通过临时只读地址加载该目录内的资源;地址不写入项目存档,关闭网页标签、刷新替换或卸载会撤销。切换项目仍保留原有最多 6 个项目保活,不等于立即撤销。网页仍能联网,不应给不可信页面挂载含敏感文件的目录。
【隔离带来的影响】部分网页的浏览器存储、Cookie 登录、嵌套页面、WebGL 贴图及标注自动取字可能受限,旧网页可能需要项目侧适配。工作台不自动改写项目网页,也不通过取消隔离恢复这些能力。窗口级标注仍可用,必要时补截图。工作台自己的项目、布局与媒体不清空、不迁移。
【分栏与侧栏】修复小数尺寸经浏览器规范化后误判、反复重排,以及会话根更换后仍观察旧元素的问题。展开和折叠侧栏共用一份弹窗层,控制室的新建入口及绑定、图标、删除弹窗不再因侧栏收起而缺失,避免重复渲染。
【预览读取】单资源上限 40 MiB,读取期间也执行限制;使用同一文件句柄并复核路径与文件身份,遇到文件替换、变化或挂载撤销则拒绝返回内容。它不是针对恶意本地进程的操作系统级文件沙箱。
【兼容与验收边界】本版只列 DSH 0.2.0-rc.2 / 0.2.1-rc.3。候选已在 Windows 隔离网页端验证主要页面、文件、网页资源与连接;用户在官方 Windows Desktop 0.2.1-rc.3 验证候选的新项目、项目打开、分栏缩放及侧栏设置弹窗正常。正式包另做安装与回归检查,不将候选页面结果扩大为正式包全功能验收。未验证模型生成全链路、真实审批/子代理及 macOS,不保证未测版本兼容;旧 0.1.x 不在本版支持范围。
【发布检查】流水线包含源码回归、独立安装、工厂门禁、14 项分栏锚点、侧栏实际组件回归、5 项 Chromium 几何回归与数据目录检查。社区 PR 的修复思路按当前源码吸收,贡献范围与来源见发布说明;采用修法不等于原 PR 已合并。
【升级提醒】公开发布以 GitHub Release 为准。安装前备份重要数据、核对其他插件兼容性;网页端重启后刷新,桌面端完整退出后通过自带 CLI 安装、再手动打开。若仍加载失败,保留第一条完整错误。这个包不包含宿主模型 HTTP 400 修复。
───────
此前版本说明(v0.4.0)
🖥️ 网页端与 Windows 桌面端适配,修复项目内容串挂
+18
View File
@@ -17,6 +17,24 @@ export function hostWebSocketUrl(
return url.href
}
/** Desktop preview assets use the Host HTTP origin with a read-only capability. */
export function hostPreviewUrl(
path: string,
surface: { protocol: string; streamBaseUrl?: string } = {
protocol: location.protocol,
streamBaseUrl: (globalThis as any).__DSH_TRANSPORT__?.streamBaseUrl,
},
): string {
if (!path.startsWith('/api/worktable/preview-static/')) throw new Error('Invalid preview path')
if (surface.protocol !== 'dsh-app:') return path
if (!surface.streamBaseUrl) throw new Error('Desktop Host preview URL unavailable')
const url = new URL(path, surface.streamBaseUrl)
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Unsupported Host preview protocol')
// Opaque sandbox requests cannot pass Desktop's dsh-app Origin fence. This
// read-only capability needs no Host cookie, so its assets use the Host origin.
return url.href
}
/** Keep update instructions on the active profile; Desktop must use its bundled CLI. */
export function worktableUpgrade(protocol = typeof location === 'undefined' ? 'http:' : location.protocol) {
const desktop = protocol === 'dsh-app:'
+131 -122
View File
@@ -2516,128 +2516,8 @@ function buildCustomLayoutPrompt(req: string): string {
}
}
if (!wide) {
// 收起态 = 等行高的正方形圆角按钮,中间只留 emoji;点击 = 进入对应项目
const railItems: { icon: string; name: string; onClick: (e: any) => void }[] = [
{ icon: CONSOLE_ICON, name: t('console.name'), onClick: (e) => clickConsoleCard(e.currentTarget as HTMLElement) },
...aliveRegisteredIds.map((id) => ({
icon: projects.iconOverrides[id] ?? metas[id]?.icon ?? '📦',
name: projects.nameOverrides[id] ?? metas[id]?.name ?? id,
onClick: () => openRailProject(id),
})),
...projects.shortcuts.map((s) => ({
icon: s.icon,
name: s.name,
onClick: () => { try { window.open(s.href, '_blank', 'noopener') } catch {} },
})),
...projects.layouts.map((l) => ({
icon: l.icon ?? '🧱',
name: projects.nameOverrides[l.id] ?? l.title,
onClick: () => openSplit(l),
})),
]
// 浮动态:fixed 定位到拖前高度(左/宽取折叠列实测几何);停靠态:文档流原位
const railStyle = isFloat && railRect
? { position: 'fixed' as const, top: float.top, left: railRect.left, width: railRect.width, zIndex: 70 }
: bottomInset > 0 ? { marginBottom: bottomInset } : undefined
return (
<div ref={rootRef} className="dsh-wt_section dsh-wt_rail" style={railStyle}>
<div className="dsh-wt_divider" />
<div className="dsh-wt_railBox">
{railItems.length > 0
? railItems.map((it, i) => (
<button key={i} type="button" className="dsh-wt_railBtn" title={it.name} aria-label={it.name} onClick={it.onClick}>
<span aria-hidden>{it.icon}</span>
</button>
))
: <span className="dsh-wt_railIcon">≡</span>}
</div>
</div>
)
}
return (
<div ref={rootRef} className={'dsh-wt_section' + (isFloat ? ' dsh-wt_float' : '')} style={isFloat ? floatStyle : dockedStyle}>
<div className="dsh-wt_divider" />
<div className="dsh-wt_header">
<button
type="button"
className="dsh-wt_handle"
data-float={isFloat ? 'true' : 'false'}
title={t('handle.title')}
aria-label={t('handle.aria')}
onPointerDown={onHandlePointerDown}
onPointerMove={onHandlePointerMove}
onPointerUp={onHandlePointerUp}
onPointerCancel={onHandlePointerUp}
onDoubleClick={resetDock}
>≡</button>
<span
className="dsh-wt_title"
title={t('handle.title')}
onPointerDown={onHandlePointerDown}
onPointerMove={onHandlePointerMove}
onPointerUp={onHandlePointerUp}
onPointerCancel={onHandlePointerUp}
onDoubleClick={resetDock}
>{t('title')}</span>
{updateInfo && (
<button
type="button"
className="dsh-wt_updateBadge"
title={t('update.badgeTitle') + ' · v' + updateInfo.latest}
aria-label={t('update.badgeTitle')}
onClick={() => setViewOptionsOpen(true)}
>{ICON_SYNC}</button>
)}
<div className="dsh-wt_actions">
<button
type="button"
className="dsh-wt_iconBtn"
aria-label={t('menu.search')}
title={t('menu.search')}
onClick={() => persistView({ searchOpen: !view.searchOpen, query: view.searchOpen ? '' : view.query })}
>{ICON_SEARCH}</button>
<button
type="button"
className="dsh-wt_iconBtn"
aria-label={t('menu.viewOptions')}
title={t('menu.viewOptions')}
onClick={() => { invalidatePickState(); setViewOptionsOpen((v) => !v); setAddOpen(false) }}
>{ICON_VIEW_OPTIONS}</button>
<button
type="button"
className="dsh-wt_iconBtn"
aria-label={t('menu.add')}
title={t('menu.add')}
onClick={() => {
invalidatePickState()
setAddOpen((v) => !v); setViewOptionsOpen(false)
// 父目录默认 = 当前会话工作目录(不落 C 盘默认位置)
if (!wsFolderParent) {
const cwd = sessionScopeStore.snapshot?.cwd ?? ''
if (cwd) setWsFolderParent(cwd)
}
}}
>{ICON_ADD}</button>
</div>
</div>
{view.searchOpen && (
<div className="dsh-wt_search">
<input
autoFocus
type="text"
placeholder={t('search.placeholder')}
value={view.query}
onChange={(e) => persistView({ query: e.target.value })}
onKeyDown={onSearchKeyDown}
/>
<button type="button" className="dsh-wt_searchClear" aria-label={t('search.close')}
onClick={() => persistView({ searchOpen: false, query: '' })}>✕</button>
</div>
)}
const popupLayer = (
<>
{addOpen && <div className="dsh-wt_popBackdrop" onClick={() => { invalidatePickState(); setAddOpen(false) }} />}
{addOpen && (
<div className="dsh-wt_menu dsh-wt_add dsh-wt_pop" style={{ position: 'fixed', left: popLeft, top: popTop, width: 360, zIndex: 80 }}>
@@ -3064,6 +2944,135 @@ function buildCustomLayoutPrompt(req: string): string {
</div>
)}
</>
)
if (!wide) {
// 收起态 = 等行高的正方形圆角按钮,中间只留 emoji;点击 = 进入对应项目
const railItems: { icon: string; name: string; onClick: (e: any) => void }[] = [
{ icon: CONSOLE_ICON, name: t('console.name'), onClick: (e) => clickConsoleCard(e.currentTarget as HTMLElement) },
...aliveRegisteredIds.map((id) => ({
icon: projects.iconOverrides[id] ?? metas[id]?.icon ?? '📦',
name: projects.nameOverrides[id] ?? metas[id]?.name ?? id,
onClick: () => openRailProject(id),
})),
...projects.shortcuts.map((s) => ({
icon: s.icon,
name: s.name,
onClick: () => { try { window.open(s.href, '_blank', 'noopener') } catch {} },
})),
...projects.layouts.map((l) => ({
icon: l.icon ?? '🧱',
name: projects.nameOverrides[l.id] ?? l.title,
onClick: () => openSplit(l),
})),
]
// 浮动态:fixed 定位到拖前高度(左/宽取折叠列实测几何);停靠态:文档流原位
const railStyle = isFloat && railRect
? { position: 'fixed' as const, top: float.top, left: railRect.left, width: railRect.width, zIndex: 70 }
: bottomInset > 0 ? { marginBottom: bottomInset } : undefined
return (
<>
<div ref={rootRef} className="dsh-wt_section dsh-wt_rail" style={railStyle}>
<div className="dsh-wt_divider" />
<div className="dsh-wt_railBox">
{railItems.length > 0
? railItems.map((it, i) => (
<button key={i} type="button" className="dsh-wt_railBtn" title={it.name} aria-label={it.name} onClick={it.onClick}>
<span aria-hidden>{it.icon}</span>
</button>
))
: <span className="dsh-wt_railIcon">≡</span>}
</div>
</div>
{popupLayer}
</>
)
}
return (
<div ref={rootRef} className={'dsh-wt_section' + (isFloat ? ' dsh-wt_float' : '')} style={isFloat ? floatStyle : dockedStyle}>
<div className="dsh-wt_divider" />
<div className="dsh-wt_header">
<button
type="button"
className="dsh-wt_handle"
data-float={isFloat ? 'true' : 'false'}
title={t('handle.title')}
aria-label={t('handle.aria')}
onPointerDown={onHandlePointerDown}
onPointerMove={onHandlePointerMove}
onPointerUp={onHandlePointerUp}
onPointerCancel={onHandlePointerUp}
onDoubleClick={resetDock}
>≡</button>
<span
className="dsh-wt_title"
title={t('handle.title')}
onPointerDown={onHandlePointerDown}
onPointerMove={onHandlePointerMove}
onPointerUp={onHandlePointerUp}
onPointerCancel={onHandlePointerUp}
onDoubleClick={resetDock}
>{t('title')}</span>
{updateInfo && (
<button
type="button"
className="dsh-wt_updateBadge"
title={t('update.badgeTitle') + ' · v' + updateInfo.latest}
aria-label={t('update.badgeTitle')}
onClick={() => setViewOptionsOpen(true)}
>{ICON_SYNC}</button>
)}
<div className="dsh-wt_actions">
<button
type="button"
className="dsh-wt_iconBtn"
aria-label={t('menu.search')}
title={t('menu.search')}
onClick={() => persistView({ searchOpen: !view.searchOpen, query: view.searchOpen ? '' : view.query })}
>{ICON_SEARCH}</button>
<button
type="button"
className="dsh-wt_iconBtn"
aria-label={t('menu.viewOptions')}
title={t('menu.viewOptions')}
onClick={() => { invalidatePickState(); setViewOptionsOpen((v) => !v); setAddOpen(false) }}
>{ICON_VIEW_OPTIONS}</button>
<button
type="button"
className="dsh-wt_iconBtn"
aria-label={t('menu.add')}
title={t('menu.add')}
onClick={() => {
invalidatePickState()
setAddOpen((v) => !v); setViewOptionsOpen(false)
// 父目录默认 = 当前会话工作目录(不落 C 盘默认位置)
if (!wsFolderParent) {
const cwd = sessionScopeStore.snapshot?.cwd ?? ''
if (cwd) setWsFolderParent(cwd)
}
}}
>{ICON_ADD}</button>
</div>
</div>
{view.searchOpen && (
<div className="dsh-wt_search">
<input
autoFocus
type="text"
placeholder={t('search.placeholder')}
value={view.query}
onChange={(e) => persistView({ query: e.target.value })}
onKeyDown={onSearchKeyDown}
/>
<button type="button" className="dsh-wt_searchClear" aria-label={t('search.close')}
onClick={() => persistView({ searchOpen: false, query: '' })}>✕</button>
</div>
)}
{popupLayer}
<div className="dsh-wt_projects" data-managing={viewOptionsOpen ? 'true' : undefined}>
{/* 「工作台」控制室项目:固定首位、不可删除;未绑定点开 = 强制绑定弹窗 */}
<button
+4 -4
View File
@@ -96,7 +96,7 @@ export const zh = {
'custom.groupNew': '+ 新建一个分组…',
'custom.groupNewParent': '分组父目录',
'custom.groupNewName': '新分组名',
'custom.groupNewParentPh': '例如 E:\\AI_Workspace\\DeepseekHarness',
'custom.groupNewParentPh': '例如 C:\\Projects',
'custom.groupNewNamePh': '例如 MyProject(将新建此文件夹并注册为分组)',
'custom.groupNeedPath': '请填写分组父目录和新分组名',
'bind.title': '绑定对话',
@@ -174,7 +174,7 @@ export const zh = {
'console.groupNew': '新建分组',
'console.chooseGroup': '选择分组…',
'console.blankNeedsGroup': '当前 DSH 的未分组空对话无法输入,请选择分组或加入现有对话。',
'console.newParentPh': '分组父目录,例如 E:\\AI_Workspace',
'console.newParentPh': '分组父目录,例如 C:\\Projects',
'console.newNamePh': '分组文件夹名(将创建并注册)',
'console.createBind': '新建并绑定',
'console.bindFail': '创建失败,请重试',
@@ -334,7 +334,7 @@ export const en = {
'custom.groupNew': '+ Create a new group…',
'custom.groupNewParent': 'Group parent folder',
'custom.groupNewName': 'New group name',
'custom.groupNewParentPh': 'e.g. E:\\AI_Workspace\\DeepseekHarness',
'custom.groupNewParentPh': 'e.g. C:\\Projects',
'custom.groupNewNamePh': 'e.g. MyProject (folder will be created and registered)',
'custom.groupNeedPath': 'Fill in the parent folder and new group name',
'bind.title': 'Bind conversation',
@@ -412,7 +412,7 @@ export const en = {
'console.groupNew': 'New group',
'console.chooseGroup': 'Choose a group…',
'console.blankNeedsGroup': 'Blank ungrouped chats cannot accept input in this DSH. Choose a group or join an existing chat.',
'console.newParentPh': 'Group parent folder, e.g. E:\\AI_Workspace',
'console.newParentPh': 'Group parent folder, e.g. C:\\Projects',
'console.newNamePh': 'Group folder name (created & registered)',
'console.createBind': 'Create & bind',
'console.bindFail': 'Creation failed, try again',
+10 -4
View File
@@ -1,6 +1,7 @@
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
import { parentPathOf } from './pathutil'
import { hostWebSocketUrl, worktableUpgrade } from './hostTransport'
import { SandboxedFrame } from './SandboxedFrame'
import { CHANGELOG_V030 } from './changelog'
import { LOCAL_VERSION, checkUpdate, getAutoCheck, readCache, setAutoCheck as storeAutoCheck, setSkipVersion, type UpdateInfo, type UpdateStatus } from './updateCheck'
import { Terminal } from 'xterm'
@@ -476,7 +477,7 @@ function boxPayload(x0: number, y0: number, x1: number, y1: number): { primary:
const r = iframe.getBoundingClientRect()
if (r.width <= 0 || r.height <= 0) continue
if (cx < r.left || cx > r.right || cy < r.top || cy > r.bottom) continue
src = iframe.getAttribute('src') ?? ''
src = iframe.getAttribute('data-source-url') ?? iframe.getAttribute('src') ?? ''
let inner: Document | null = null
try { inner = iframe.contentDocument } catch { inner = null }
if (inner) readDoc(inner, r.left, r.top)
@@ -939,6 +940,8 @@ export const splitStore: SplitState = {
this.savedMarginLeft = viewArea.style.marginLeft
this.savedMarginRight = viewArea.style.marginRight
this.savedMarginTop = viewArea.style.marginTop
this.yieldObserver?.disconnect()
this.yieldObserver?.observe(viewArea, { attributes: true, attributeFilter: ['style'] })
}
this.observer?.disconnect()
this.observer.observe(next)
@@ -988,6 +991,9 @@ export const splitStore: SplitState = {
viewArea.style.marginLeft = this.lastMarginLeft
viewArea.style.marginRight = this.lastMarginRight
viewArea.style.marginTop = mt
this.lastMarginLeft = viewArea.style.marginLeft
this.lastMarginRight = viewArea.style.marginRight
this.lastMarginTop = viewArea.style.marginTop
},
setChatW(w) {
@@ -1429,14 +1435,14 @@ function BrowserPane(props: { row: PaneRow; index: number; tabId: string; conten
/>
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
</div>
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="browser" />
<SandboxedFrame url={src} title="browser" reloadKey={props.reloadKey} />
</>
)
}
/** iframe 内容标签(网页/站点产物):刷新统一在标签栏最左(重挂载整页刷新,跨域可靠) */
function IframePane(props: { url: string; title?: string; reloadKey: number }) {
return <iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={props.url} title={props.title ?? ''} />
return <SandboxedFrame {...props} />
}
/** 动画播放窗:iframe 壳 + 地址栏(站内自带项目/场景列表、播放、画幅切换、导出等全部控件) */
@@ -1464,7 +1470,7 @@ function AnimPane(props: { row: PaneRow; index: number; tabId: string; content:
/>
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
</div>
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="anim" />
<SandboxedFrame url={src} title="anim" reloadKey={props.reloadKey} />
</>
)
}
+101 -21
View File
@@ -6,6 +6,8 @@ import { dirname, resolve as pathResolve, sep } from 'node:path'
import { createRequire } from 'node:module'
import { homedir } from 'node:os'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { requestRejection, rejectionText } from './requestAuth'
import { createPreviewFiles, PREVIEW_PREFIX, PREVIEW_CSP } from './previewFiles'
/**
* 基础数据目录解析:不加载任何官方包(loadPkg 的兜底只能用它,禁止反向调用包加载函数——否则成环)。
@@ -149,7 +151,13 @@ function json(res: any, status: number, body: unknown) {
async function readJsonBody(req: any): Promise<any> {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
let bytes = 0
for await (const chunk of req) {
const data = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
bytes += data.length
if (bytes > 22 * 1024 * 1024) throw Object.assign(new Error('request too large'), { status: 413 })
chunks.push(data)
}
const text = Buffer.concat(chunks).toString('utf8')
if (!text) return {}
try { return JSON.parse(text) } catch { return {} }
@@ -207,6 +215,15 @@ function setupTerminal(webServer: any, ctx: any) {
if (!WebSocketServer) return
const pty = ptyMod.default ?? ptyMod
const wss = new WebSocketServer({ noServer: true })
let disposed = false
const terminals = new Set<any>()
ctx.effect(() => () => {
disposed = true
for (const ws of wss.clients ?? []) { try { ws.terminate() } catch {} }
for (const term of terminals) { try { term.kill() } catch {} }
terminals.clear()
try { wss.close() } catch {}
}, 'dsh-worktable: terminal sessions')
const spawnShell = (): { cmd: string; args: string[] } =>
process.platform === 'win32'
? { cmd: 'powershell.exe', args: ['-NoLogo', '-NoProfile'] } // -NoProfile:跳过用户配置(oh-my-posh 花哨提示符在 xterm 里是乱码,PSReadLine 长输入行不换行被截断)
@@ -215,7 +232,13 @@ function setupTerminal(webServer: any, ctx: any) {
ctx.effect(() => webServer.registerUpgrade({
path: '/api/worktable/term',
handler: (req: any, socket: any, head: any) => {
handler: async (req: any, socket: any, head: any) => {
let rejected = await requestRejection(ctx, req)
if (disposed) rejected = 503
if (rejected !== undefined) {
socket.end(`HTTP/1.1 ${rejected} ${rejectionText(rejected)}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
return
}
wss.handleUpgrade(req, socket, head, (ws: any) => {
const u = new URL(req.url ?? '/', 'http://dsh.internal')
const cwd = serverCwd(ctx, u.searchParams.get('sessionId') || undefined, u.searchParams.get('cwd') || undefined)
@@ -225,13 +248,14 @@ function setupTerminal(webServer: any, ctx: any) {
try {
const shell = spawnShell()
term = pty.spawn(shell.cmd, shell.args, { name: 'xterm-256color', cols, rows, cwd, env: process.env })
terminals.add(term)
} catch (err) {
try { ws.send('\r\n[worktable] 终端启动失败:' + String(err)) } catch {}
try { ws.close() } catch {}
return
}
term.onData((d: string) => { try { ws.send(d) } catch {} })
term.onExit(() => { try { ws.close() } catch {} })
term.onExit(() => { terminals.delete(term); try { ws.close() } catch {} })
ws.on('message', (raw: any) => {
const text = String(raw)
try {
@@ -243,7 +267,7 @@ function setupTerminal(webServer: any, ctx: any) {
} catch {}
try { term.write(text) } catch {}
})
ws.on('close', () => { try { term.kill() } catch {} })
ws.on('close', () => { terminals.delete(term); try { term.kill() } catch {} })
})
},
}), 'dsh-worktable: terminal upgrade')
@@ -256,7 +280,57 @@ export function apply(ctx: Context) {
return
}
webServer.register({
const previews = createPreviewFiles()
ctx.effect(() => () => previews.clear(), 'dsh-worktable: preview capabilities')
// Direct WebServer routes do NOT inherit the host's /api authentication route.
const register = (route: any, publicAsset = false) => ctx.effect(() => webServer.register({
...route,
handler: async (req: any, res: any) => {
if (!publicAsset && route.path !== HEALTH_PATH) {
const rejected = await requestRejection(ctx, req)
if (rejected !== undefined) { json(res, rejected, { error: rejectionText(rejected) }); return }
}
return route.handler(req, res)
},
}), 'dsh-worktable: ' + route.path)
register({
kind: 'exact', path: '/api/worktable/preview',
handler: async (req: any, res: any) => {
if (req.method !== 'POST') { json(res, 405, { error: 'method not allowed' }); return }
try {
const body = await readJsonBody(req)
if (typeof body.revoke === 'string') { previews.revoke(body.revoke); json(res, 200, { ok: true }); return }
if (typeof body.root !== 'string') { json(res, 400, { error: 'missing root' }); return }
const token = await previews.mount(body.root)
json(res, 200, { token, base: PREVIEW_PREFIX + '/' + token + '/' })
} catch (err: any) { json(res, err.status ?? 400, { error: String(err) }) }
},
})
register({
kind: 'prefix', path: PREVIEW_PREFIX,
handler: async (req: any, res: any) => {
// A sandboxed document has an opaque origin. It can read only this capability's
// static directory, without credentials; it cannot pass the host API admission.
if (req.method !== 'GET' && req.method !== 'HEAD') { json(res, 405, { error: 'method not allowed' }); return }
try {
const parts = new URL(req.url ?? '/', 'http://dsh.internal').pathname.slice(PREVIEW_PREFIX.length + 1).split('/').map(decodeURIComponent)
const file = await previews.file(parts.shift() ?? '', parts.filter(Boolean), req.method === 'HEAD')
if (!file) { json(res, 404, { error: 'preview unavailable; refresh to reopen' }); return }
const ext = (file.path.split('.').pop() || '').toLowerCase()
res.writeHead(200, {
'content-type': FILE_TYPES[ext] ?? 'application/octet-stream',
'cache-control': 'no-store', 'referrer-policy': 'no-referrer',
'content-security-policy': PREVIEW_CSP, 'x-content-type-options': 'nosniff',
'access-control-allow-origin': '*',
})
res.end(file.data)
} catch (err: any) { json(res, err?.status === 413 ? 413 : 404, { error: err?.status === 413 ? 'file too large' : 'preview unavailable' }) }
},
}, true)
register({
kind: 'exact',
path: HEALTH_PATH,
handler: (_req: any, res: any) => {
@@ -265,7 +339,7 @@ export function apply(ctx: Context) {
})
// 本地文件读取(资源管理器点击 .html 后浏览器标签内打开)
webServer.register({
register({
kind: 'exact',
path: '/api/worktable/file',
handler: async (req: any, res: any) => {
@@ -284,7 +358,10 @@ export function apply(ctx: Context) {
json: 'application/json; charset=utf-8', md: 'text/markdown; charset=utf-8', markdown: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8', log: 'text/plain; charset=utf-8',
pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon',
}
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
...(/^(html?|svg)$/.test(ext) ? { 'content-security-policy': PREVIEW_CSP, 'referrer-policy': 'no-referrer' } : {}),
})
res.end(data)
} catch (err) {
json(res, 404, { error: String(err) })
@@ -295,7 +372,7 @@ export function apply(ctx: Context) {
// 本地站点(目录级静态托管):点开 index.html 时挂载整个所在目录,
// 让 ./assets/... 等相对引用正常解析(前缀路由,余下路径 = <rootToken>/<相对路径>)。
// 原生皮肤模板:HTML 骨架 + 设计系统样式表(随插件分发,主题自动适配)
webServer.register({
register({
kind: 'prefix',
path: TEMPLATE_PREFIX,
handler: (req: any, res: any) => {
@@ -316,7 +393,7 @@ export function apply(ctx: Context) {
},
})
webServer.register({
register({
kind: 'prefix',
path: SITE_PREFIX,
handler: async (req: any, res: any) => {
@@ -340,7 +417,8 @@ export function apply(ctx: Context) {
if (info.size > 40 * 1024 * 1024) { json(res, 413, { error: 'file too large' }); return }
const data = await readFile(abs)
const ext = (abs.split('.').pop() || '').toLowerCase()
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store',
'content-security-policy': PREVIEW_CSP, 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff' })
res.end(data)
} catch (err) {
json(res, 404, { error: String(err) })
@@ -348,7 +426,7 @@ export function apply(ctx: Context) {
},
})
webServer.register({
register({
kind: 'exact',
path: '/api/worktable/fs',
handler: async (req: any, res: any) => {
@@ -359,7 +437,7 @@ export function apply(ctx: Context) {
: serverCwd(ctx, body.sessionId, body.cwd)
json(res, 200, await listDirectory(path))
} catch (err) {
json(res, 500, { path: '', entries: [], truncated: false, error: String(err) })
json(res, (err as any).status ?? 500, { path: '', entries: [], truncated: false, error: String(err) })
}
},
})
@@ -368,7 +446,7 @@ export function apply(ctx: Context) {
// 优先走宿主正式服务 ctx.workspaceRegistry(0.1.1/0.1.2 均有,正确感知 DSH_HOME 与存储后端);
// 不可用时回退按 resolveDshHomeSafe() 读 storages/workspace.json(只读)。
// 返回结构是客户端契约,两种来源都映射成同一 shape。
webServer.register({
register({
kind: 'exact',
path: '/api/worktable/workspaces',
handler: async (_req: any, res: any) => {
@@ -412,7 +490,7 @@ export function apply(ctx: Context) {
})
// 本地文件写入(MD 编辑模式保存回磁盘)
webServer.register({
register({
kind: 'exact',
path: '/api/worktable/write',
handler: async (req: any, res: any) => {
@@ -427,13 +505,13 @@ export function apply(ctx: Context) {
await import('node:fs/promises').then((m) => m.writeFile(abs, content, 'utf8'))
json(res, 200, { ok: true })
} catch (err) {
json(res, 500, { error: String(err) })
json(res, (err as any).status ?? 500, { error: String(err) })
}
},
})
// 新建分组:创建目录(仅当父目录已存在,避免递归误建深层垃圾目录)
webServer.register({
register({
kind: 'exact',
path: '/api/worktable/mkdir',
handler: async (req: any, res: any) => {
@@ -449,18 +527,20 @@ export function apply(ctx: Context) {
await fsx.mkdir(abs)
json(res, 200, { ok: true, path: abs })
} catch (err: any) {
json(res, err?.code === 'EEXIST' ? 200 : 500, err?.code === 'EEXIST' ? { ok: true, exists: true } : { error: String(err) })
json(res, err?.code === 'EEXIST' ? 200 : err.status ?? 500, err?.code === 'EEXIST' ? { ok: true, exists: true } : { error: String(err) })
}
},
})
webServer.register({
register({
kind: 'exact',
path: '/api/worktable/git',
handler: async (req: any, res: any) => {
const body = await readJsonBody(req)
const cwd = serverCwd(ctx, body.sessionId, body.cwd)
json(res, 200, await gitStatus(cwd))
try {
const body = await readJsonBody(req)
const cwd = serverCwd(ctx, body.sessionId, body.cwd)
json(res, 200, await gitStatus(cwd))
} catch (err: any) { json(res, err.status ?? 500, { error: String(err) }) }
},
})
+93
View File
@@ -0,0 +1,93 @@
import { randomBytes } from 'node:crypto'
import { open, realpath, stat } from 'node:fs/promises'
import { isAbsolute, relative, resolve, sep } from 'node:path'
export const PREVIEW_PREFIX = '/api/worktable/preview-static'
export const PREVIEW_SANDBOX = 'allow-scripts allow-forms allow-downloads'
export const PREVIEW_CSP = `sandbox ${PREVIEW_SANDBOX}; base-uri 'none'; object-src 'none'; frame-src 'none'`
export const PREVIEW_MAX_BYTES = 40 * 1024 * 1024
const IDLE_MS = 30 * 60 * 1000
const MAX_MOUNTS = 128
function inside(root: string, file: string): boolean {
const rel = relative(root, file)
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + sep))
}
/** Short-lived read-only capabilities. They grant one directory, never host APIs. */
export function createPreviewFiles(now = () => Date.now()) {
const mounts = new Map<string, { root: string; touched: number }>()
const prune = () => { for (const [key, item] of mounts) if (now() - item.touched >= IDLE_MS) mounts.delete(key) }
return {
async mount(root: string) {
if (!isAbsolute(root)) throw new Error('Preview root must be absolute')
const canonical = await realpath(root)
if (!(await stat(canonical)).isDirectory()) throw new Error('Preview root is not a directory')
prune()
if (mounts.size >= MAX_MOUNTS) throw new Error('Too many open previews; close an unused preview first')
const token = randomBytes(24).toString('base64url')
mounts.set(token, { root: canonical, touched: now() })
return token
},
revoke(token: string) { mounts.delete(token) },
clear() { mounts.clear() },
async file(token: string, segments: string[], headOnly = false) {
prune()
const mount = mounts.get(token)
if (!mount) return null
// Decode exactly once before this call; forbid separators embedded in a segment.
if (segments.some(s => s === '..' || s.includes('/') || s.includes('\\') || s.includes('\0'))) return null
let target = resolve(mount.root, ...segments)
if (!inside(mount.root, target)) return null
let info = await stat(target).catch(() => null)
if (info?.isDirectory()) target = resolve(target, 'index.html')
const canonical = await realpath(target).catch(() => null)
if (!canonical || !inside(mount.root, canonical)) return null
// Keep the read bound to one handle. Recheck path/identity around the read
// to reject ordinary replacement races; this is not an OS filesystem jail
// against an adversarial local process coordinating ABA path swaps.
const handle = await open(canonical, 'r').catch(() => null)
if (!handle) return null
try {
const before = await handle.stat({ bigint: true })
if (!before.isFile()) return null
const sameFile = (other: typeof before) => other.isFile()
&& other.dev === before.dev && other.ino === before.ino
&& other.size === before.size && other.mtimeNs === before.mtimeNs
&& other.ctimeNs === before.ctimeNs
const stillValid = async () => {
const current = await realpath(canonical)
if (current !== canonical || !inside(mount.root, current)) return false
const named = await stat(current, { bigint: true })
const opened = await handle.stat({ bigint: true })
return sameFile(named) && sameFile(opened)
&& mounts.get(token) === mount && now() - mount.touched < IDLE_MS
}
const tooLarge = () => Object.assign(new Error('file too large'), { status: 413 })
if (before.size > BigInt(PREVIEW_MAX_BYTES)) throw tooLarge()
if (!(await stillValid())) return null
const chunks: Buffer[] = []
let total = 0
if (!headOnly) {
// A file may grow after stat. Never allocate/read an unbounded body.
while (total <= PREVIEW_MAX_BYTES) {
const chunk = Buffer.alloc(Math.min(64 * 1024, PREVIEW_MAX_BYTES + 1 - total))
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total)
if (!bytesRead) break
total += bytesRead
if (total > PREVIEW_MAX_BYTES) throw tooLarge()
chunks.push(chunk.subarray(0, bytesRead))
}
}
if (!(await stillValid()) || (!headOnly && BigInt(total) !== before.size)) return null
mount.touched = now()
return { path: canonical, size: Number(before.size), data: headOnly ? undefined : Buffer.concat(chunks, total) }
} catch (error: any) {
if (error?.status === 413) throw error
return null
} finally {
await handle.close()
}
},
}
}
+23
View File
@@ -0,0 +1,23 @@
/** Use the host's current authentication service for every request, never a cached grant. */
export async function requestRejection(ctx: any, req: any): Promise<number | undefined> {
try {
const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
if (!connection) return 503
if (typeof connection.admit === 'function') {
const admission = await connection.admit(req)
if (!admission || typeof admission !== 'object') return 503
if ('rejection' in admission) return admission.rejection === 401 ? 401 : 403
const peer = admission.peer
// 0.2.1 has owner/non-owner principals. 0.2.0 has a single operator Peer.
if (peer?.principal?.kind === 'owner') return undefined
if (peer && peer === connection.operator && peer.principal === undefined) return undefined
return 403
}
} catch {}
// An unavailable or failed security service must not turn into public filesystem access.
return 503
}
export function rejectionText(status: number): string {
return status === 401 ? 'unauthorized' : status === 403 ? 'forbidden' : 'Host authentication unavailable'
}