dsh-vscode-workbench 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-workbench",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Explorer, Monaco editor, search and local Git overlay for DSH",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,7 +17,8 @@
17
17
  "registry": "https://registry.npmjs.org"
18
18
  },
19
19
  "engines": {
20
- "node": "^22.19.0 || >=24.0.0"
20
+ "node": "^22.19.0 || >=24.0.0",
21
+ "dsh": "^0.2.1-rc.4"
21
22
  },
22
23
  "main": "lib/index.js",
23
24
  "types": "lib/types/index.d.ts",
@@ -47,7 +48,8 @@
47
48
  "client": {
48
49
  "platform": "web",
49
50
  "inject": [
50
- "@deepseek-ai/dsh-client-runtime",
51
+ "@deepseek-ai/dsh-client-ui-renderer",
52
+ "@deepseek-ai/dsh-client-ui-session",
51
53
  "@deepseek-ai/dsh-client-ui-layout",
52
54
  "@deepseek-ai/dsh-client-ui-sidebar"
53
55
  ]
@@ -68,33 +70,45 @@
68
70
  "ws": "^8.18.0"
69
71
  },
70
72
  "peerDependencies": {
71
- "@deepseek-ai/cordis": "^4.0.1",
72
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
73
- "@deepseek-ai/dsh-client-ui-layout": "^0.1.1-rc.2",
74
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.1-rc.2",
75
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
76
- "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
77
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
73
+ "@deepseek-ai/dsh-api-session-controller": "^0.2.1-rc.4",
74
+ "@deepseek-ai/cordis": "^4.0.5",
75
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.2.1-rc.4",
76
+ "@deepseek-ai/dsh-client-ui-session": "^0.2.1-rc.4",
77
+ "@deepseek-ai/dsh-client-ui-layout": "^0.2.1-rc.4",
78
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.1-rc.4",
79
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.1-rc.4",
80
+ "@deepseek-ai/dsh-client-connection": "^0.2.1-rc.4",
81
+ "@deepseek-ai/dsh-host-webserver": "^0.2.1-rc.4",
82
+ "@deepseek-ai/dsh-working-directory": "^0.2.1-rc.4",
83
+ "@deepseek-ai/dsh-session": "^0.2.1-rc.4",
78
84
  "react": "^18.2.0",
79
85
  "react-dom": "^18.2.0"
80
86
  },
81
87
  "devDependencies": {
82
- "@deepseek-ai/cordis": "4.0.1",
83
- "@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
84
- "@deepseek-ai/dsh-client-ui-layout": "0.1.1-rc.2",
85
- "@deepseek-ai/dsh-client-ui-sidebar": "0.1.1-rc.2",
86
- "@deepseek-ai/dsh-client-ui-slots": "0.1.1-rc.2",
87
- "@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
88
- "@deepseek-ai/dsh-session": "0.1.1-rc.2",
88
+ "@deepseek-ai/dsh-api-session-controller": "0.2.1-rc.4",
89
+ "@deepseek-ai/dsh-client-store": "0.2.1-rc.4",
90
+ "@deepseek-ai/cordis": "4.0.5",
91
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.4",
92
+ "@deepseek-ai/dsh-client-ui-session": "0.2.1-rc.4",
93
+ "@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.4",
94
+ "@deepseek-ai/dsh-client-ui-sidebar": "0.2.1-rc.4",
95
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.4",
96
+ "@deepseek-ai/dsh-client-connection": "0.2.1-rc.4",
97
+ "@deepseek-ai/dsh-host-webserver": "0.2.1-rc.4",
98
+ "@deepseek-ai/dsh-working-directory": "0.2.1-rc.4",
99
+ "@deepseek-ai/dsh-session": "0.2.1-rc.4",
89
100
  "@types/node": "^24.0.0",
101
+ "@types/jsdom": "^28.0.3",
90
102
  "@types/react": "^18.3.0",
91
103
  "@types/react-dom": "^18.3.0",
92
104
  "@types/ws": "^8.18.0",
93
105
  "react": "^18.2.0",
94
106
  "react-dom": "^18.3.1",
107
+ "jsdom": "29.1.1",
95
108
  "tsdown": "^0.22.2",
96
109
  "typescript": "^5.9.0",
97
- "vitest": "^4.0.0"
110
+ "vitest": "^4.0.0",
111
+ "@deepseek-ai/dsh-app-boot": "0.2.1-rc.4"
98
112
  },
99
113
  "scripts": {
100
114
  "clean": "node scripts/clean.mjs lib dist",
@@ -2,8 +2,8 @@ import { useLayoutEffect } from 'react'
2
2
 
3
3
  /** Locate the resident DSH conversation without depending on generated CSS module names. */
4
4
  export function findDshConversationRoot(documentRoot: Document = document): HTMLElement | null {
5
- const scroller = documentRoot.querySelector<HTMLElement>('[data-conversation-scroll]')
6
- const root = scroller?.parentElement
5
+ const scroller = documentRoot.querySelector<HTMLElement>('[data-phase] [data-conversation-scroll]')
6
+ const root = scroller?.closest('[data-phase]')
7
7
  return root instanceof HTMLElement && root.dataset.phase !== undefined ? root : null
8
8
  }
9
9
 
@@ -279,10 +279,9 @@ function Git(props: { sessionId: string; onDiff(key:string, diff:GitDiff, title?
279
279
 
280
280
  export function Launcher({ service }: { service: VscodeWorkbench }) { return <button className="dvw-launch" title="打开 VS Code 工作台" onClick={()=>service.show()}>VS Code 工作台</button> }
281
281
 
282
- export function Workbench({ service, useSessions }: { service: VscodeWorkbench; useSessions: (selector: (state: any) => any) => any }) {
282
+ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbench; sessionId?: string; cwd?: string }) {
283
283
  const [snapshot, setSnapshot] = useState(() => service.getSnapshot()); const [panels, setPanels] = useState(() => service.getPanels())
284
284
  useEffect(() => service.subscribe(() => { setSnapshot(service.getSnapshot()); setPanels(service.getPanels()) }), [service])
285
- const sessionId = useSessions(state=>state.current) as string|undefined; const cwd = useSessions(state=>sessionId===undefined?undefined:state.byId[sessionId]?.cwd) as string|undefined
286
285
  const [workspace,setWorkspace]=useState<{cwd:string;name:string}>(); const [tabs,setTabs]=useState<Tab[]>([]); const [active,setActive]=useState<string>(); const [error,setError]=useState<string>(); const [panelHeight,setPanelHeight]=useState(260); const [sideWidth,setSideWidth]=useState(210); const [primarySideVisible,setPrimarySideVisible]=useState(()=>localStorage.getItem('dvw:primary-side-visible')!=='closed');const [statusVisible,setStatusVisible]=useState(()=>localStorage.getItem('dvw:status-visible')!=='closed');const [settingsOpen,setSettingsOpen]=useState(false);const [chatWidth,setChatWidth]=useState(()=>Math.max(400,Math.min(720,Math.round(Number(localStorage.getItem('dvw:dsh-chat-width'))||420))));const [chatVisible,setChatVisible]=useState(()=>localStorage.getItem('dvw:dsh-chat-visible')!=='closed');const [outlineVisible,setOutlineVisible]=useState(()=>localStorage.getItem('dvw:outline')!=='closed')
287
286
  const tabsRef=useRef(tabs);tabsRef.current=tabs
288
287
  const workspaceRequests=useRef(new WorkspaceRequestScope());const loadingTabs=useRef(new Set<string>())
@@ -326,7 +325,7 @@ export function Workbench({ service, useSessions }: { service: VscodeWorkbench;
326
325
  <aside className="dvw-side" data-side-width={sideWidth}>{snapshot.view==='explorer'&&<Explorer sessionId={sessionId} cwd={workspace?.cwd} selected={active} onOpen={path=>void open(path)}/>} {snapshot.view==='search'&&<Search sessionId={sessionId} onOpen={(path,line,column)=>void open(path,line,column)}/>} {snapshot.view==='git'&&<Git sessionId={sessionId} onDiff={(path,diff,title,diffSourcePath)=>{const key=`diff:${path}`;setTabs(rows=>[...rows.filter(row=>row.path!==key),{path:key,title,content:diff.diff,draft:diff.diff,revision:'',size:diff.diff.length,binary:false,tooLarge:false,dirty:false,diff,diffMode:'inline',diffSourcePath}]);setActive(key)}}/>}</aside>
327
326
  <PanelResizer className="dvw-side-resizer" label="拖动调整源码管理侧边栏宽度" width={sideWidth} min={210} max={640} defaultWidth={210} direction={1} onWidth={setSideWidth}/>
328
327
  <main className="dvw-main"><div className="dvw-tabs"><div className="dvw-tab-list">{tabs.map(tab=><button className="dvw-tab" data-active={tab.path===active} key={tab.path} title={tab.diffSourcePath??tab.path} onClick={()=>setActive(tab.path)}><span className="dvw-tab-name">{tab.dirty?'● ':''}{tabLabel(tab)}</span><span onClick={event=>{event.stopPropagation();close(tab)}}><Icon name="close"/></span></button>)}</div>{tabs.length>0&&<div className="dvw-tab-actions"><button className="dvw-icon dvw-close-all" title="全部关闭" onClick={closeAll}><Icon name="close-all"/></button></div>}</div><div className="dvw-editor" data-outline={markdownPreview&&outlineVisible&&headings.length>0}>{error&&<div className="dvw-error"><button className="dvw-icon" onClick={()=>setError(undefined)}><Icon name="close"/></button>{error}</div>}{markdownPreview&&outlineVisible&&headings.length>0&&<MarkdownOutline headings={headings} onClose={()=>setOutlineVisible(false)} onHeading={selectHeading}/>} {markdown&&activeTab&&<div className="dvw-editor-toolbar"><button className="dvw-icon dvw-outline-toggle" data-active={outlineVisible} title={outlineVisible?'关闭 Markdown 大纲':'显示 Markdown 大纲'} onClick={()=>setOutlineVisible(value=>!value)}><Icon name="list-tree"/></button><button className="dvw-mode-button" data-active={!activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:false}:tab))}><Icon name="code"/> 源码</button><button className="dvw-mode-button" data-active={activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:true}:tab))}><Icon name="preview"/> 预览</button></div>}{activeTab?.diff&&<div className="dvw-editor-toolbar dvw-diff-toolbar">{diffMarkdown&&<div className="dvw-diff-preview-switch"><button className="dvw-mode-button" data-diff-view="diff" data-active={!activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:false}:tab))}><Icon name="diff"/> 差异</button><button className="dvw-mode-button" data-diff-view="preview" data-active={activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:true}:tab))}><Icon name="preview"/> 预览</button></div>}{!activeTab.preview&&<><button className="dvw-mode-button dvw-diff-mode-button" data-mode="inline" data-active={activeTab.diffMode!=='split'} title="内联视图" onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,diffMode:'inline'}:tab))}><Icon name="diff-single"/> 内联</button><button className="dvw-mode-button dvw-diff-mode-button" data-mode="split" data-active={activeTab.diffMode==='split'} title="并排视图" onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,diffMode:'split'}:tab))}><Icon name="diff-sidebyside"/> 并排</button></>}</div>}<div className="dvw-editor-body" data-toolbar={markdown||activeTab?.diff!==undefined}>{activeTab===undefined?<div className="dvw-empty"><div><strong>DSH Workbench</strong><br/>从资源管理器或搜索结果打开文件</div></div>:activeTab.diff?diffMarkdown&&activeTab.preview?<MarkdownPreview source={activeTab.diff.modified} sessionId={sessionId} path={activeTab.diffSourcePath??''}/>:<MonacoDiffEditor uri={`${workspace?.cwd??''}/${activeTab.path}`} original={activeTab.diff.original} modified={activeTab.diff.modified} mode={activeTab.diffMode??'inline'} theme={colorScheme}/>:activeTab.imageUrl?<div className="dvw-image-preview"><img src={activeTab.imageUrl} alt={activeTab.path}/><span>{activeTab.path}</span></div>:activeTab.documentPreview&&activeTab.documentUrl?<DocumentPreview kind={activeTab.documentPreview} src={activeTab.documentUrl} path={activeTab.path}/>:markdown&&activeTab.preview?<MarkdownPreview source={activeTab.draft} sessionId={sessionId} path={activeTab.path}/>:<MonacoEditor uri={`${workspace?.cwd??''}/${activeTab.path}`} value={activeTab.draft} theme={colorScheme} line={activeTab.line} column={activeTab.column} onChange={draft=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,draft,dirty:draft!==tab.content}:tab))} onSave={()=>void save(activeTab)}/>}</div></div></main>
329
- {snapshot.bottomPanel&&<section className="dvw-bottom" style={{height:panelHeight}}><div style={{height:4,cursor:'ns-resize'}} onPointerDown={event=>{const startY=event.clientY,startHeight=panelHeight;const move=(e:PointerEvent)=>setPanelHeight(Math.max(120,Math.min(window.innerHeight-160,startHeight+startY-e.clientY)));const up=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up)};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up)}}/><div className="dvw-bottom-head"><button className="dvw-bottom-title">{panels.find(p=>p.id===snapshot.bottomPanel)?.title??snapshot.bottomPanel}</button>{snapshot.bottomPanel==='terminal'&&<button className="dvw-icon" title="新建终端" onClick={()=>window.dispatchEvent(new Event('dvw-terminal-new'))}><Icon name="add"/></button>}<button className="dvw-icon" style={{marginLeft:'auto'}} onClick={()=>service.toggleBottomPanel(snapshot.bottomPanel!)}><Icon name="close"/></button></div><div className="dvw-bottom-body">{Panel&&cwd&&<Panel sessionId={sessionId} cwd={cwd} visible colorScheme={colorScheme}/>}</div></section>}
328
+ {snapshot.bottomPanel&&<section className="dvw-bottom" style={{height:panelHeight}}><div style={{height:4,cursor:'ns-resize'}} onPointerDown={event=>{const startY=event.clientY,startHeight=panelHeight;const move=(e:PointerEvent)=>setPanelHeight(Math.max(120,Math.min(window.innerHeight-160,startHeight+startY-e.clientY)));const up=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up)};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up)}}/><div className="dvw-bottom-head"><button className="dvw-bottom-title">{panels.find(p=>p.id===snapshot.bottomPanel)?.title??snapshot.bottomPanel}</button>{snapshot.bottomPanel==='terminal'&&<button className="dvw-icon" title="新建终端" onClick={()=>window.dispatchEvent(new Event('dvw-terminal-new'))}><Icon name="add"/></button>}<button className="dvw-icon" style={{marginLeft:'auto'}} onClick={()=>service.toggleBottomPanel(snapshot.bottomPanel!)}><Icon name="close"/></button></div><div className="dvw-bottom-body">{Panel&&workspace&&<Panel sessionId={sessionId} cwd={workspace.cwd} visible colorScheme={colorScheme}/>}</div></section>}
330
329
  <footer className="dvw-status"><span><Icon name="git-branch"/> 工作区</span><span>{workspace?.cwd??cwd??''}</span>{activeTab?.external&&<span>磁盘文件已变化</span>}<button className="dvw-icon" style={{color:'white',marginLeft:'auto'}} onClick={()=>service.hide()}>返回 DSH</button></footer>
331
330
  </div>
332
331
  }
@@ -1,8 +1,11 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
1
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
2
2
  import { useEffect, useLayoutEffect, useState } from 'react'
3
3
  import { createPortal } from 'react-dom'
4
4
  import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
5
5
  import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
6
+ import type {} from '@deepseek-ai/dsh-client-ui-session/client'
7
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
8
+ import type {} from '@deepseek-ai/dsh-working-directory'
6
9
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
7
10
  import type { VscodeWorkbench } from './service.ts'
8
11
  import { WorkbenchController } from './service.ts'
@@ -10,15 +13,16 @@ import { TerminalPanel } from './TerminalPanel.tsx'
10
13
  import { Launcher, Workbench } from './Workbench.tsx'
11
14
  import { installStyles } from './styles.ts'
12
15
  import { handleTerminalShortcut } from './shortcuts.ts'
16
+ import { mainViewSession } from './session.ts'
13
17
 
14
18
  export type { BottomPanelContribution, BottomPanelProps, VscodeWorkbench, WorkbenchCommand, WorkbenchSnapshot, WorkbenchView } from './service.ts'
15
19
 
16
20
  declare module '@deepseek-ai/cordis' { interface Context { vscodeWorkbench: VscodeWorkbench } }
17
- declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotInjectMap { 'dsh-vscode-workbench.overlay': { service: VscodeWorkbench }; 'dsh-vscode-workbench.launcher': { service: VscodeWorkbench } } }
18
21
 
19
22
  const Overlay = ({ service, useSessions }: PropsRuntime<'shell.overlay'> & { service: VscodeWorkbench }) => {
20
- const sessionId = useSessions(state => state.current) as string | undefined
21
- return <Workbench key={sessionId ?? 'no-session'} service={service} useSessions={useSessions as never}/>
23
+ const session = useSessions(mainViewSession)
24
+ const cwd = session?.projectionValues?.workingDirectory ?? session?.cwd
25
+ return <Workbench key={`${session?.id ?? 'no-session'}:${cwd ?? ''}`} service={service} sessionId={session?.id} cwd={cwd}/>
22
26
  }
23
27
  const Footer = ({ service }: PropsRuntime<'sidebar.footer.action'> & { service: VscodeWorkbench }) => {
24
28
  const [target, setTarget] = useState<HTMLElement>()
@@ -0,0 +1,7 @@
1
+ import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
2
+ import type {} from '@deepseek-ai/dsh-client-ui-session/client'
3
+
4
+ /** Background retains do not select the workspace shown in the main conversation. */
5
+ export function mainViewSession(state: SessionListState) {
6
+ return Object.values(state.byId).find(session => (session.retainedBy.mainView ?? 0) > 0)
7
+ }
package/src/context.ts CHANGED
@@ -1,16 +1,18 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http'
2
2
  import type { Duplex } from 'node:stream'
3
3
  import type { Context as CordisContext } from '@deepseek-ai/cordis'
4
+ import type { Session } from '@deepseek-ai/dsh-session'
4
5
 
5
6
  export interface WorkbenchWebServer {
6
7
  register(route: { kind: 'exact' | 'prefix'; path: string; handler(req: IncomingMessage, res: ServerResponse): void | Promise<void> }): () => void
7
8
  registerUpgrade(route: { path: string; handler(req: IncomingMessage, socket: Duplex, head: Buffer): void | Promise<void> }): () => void
8
9
  }
9
- export interface WorkbenchSessions { get(id: string): { header: { cwd?: string } } | undefined }
10
- export interface WorkbenchWebRuntime { trustedHosts: readonly string[] }
10
+ export interface WorkbenchSessions { get(id: string): Session | undefined }
11
+ export interface WorkbenchConnection { requestRejection(req: IncomingMessage): 401 | 403 | undefined }
11
12
  export type HostContext = CordisContext & {
12
13
  webServer: WorkbenchWebServer
13
14
  sessions: WorkbenchSessions
14
- webRuntime: WorkbenchWebRuntime
15
+ connection: WorkbenchConnection
16
+ workingDirectory: { get(session: Session): string }
15
17
  effect(callback: () => void | (() => void), label?: string): void
16
18
  }
package/src/host.ts CHANGED
@@ -13,8 +13,9 @@ const TEXT_LIMIT = 5 * 1024 * 1024
13
13
  const PROCESS_TIMEOUT = 30_000
14
14
 
15
15
  export function cwdFor(ctx: HostContext, sessionId: string): string {
16
- const cwd = ctx.sessions.get(sessionId)?.header.cwd
17
- if (cwd === undefined || cwd === '') return process.cwd()
16
+ const session = ctx.sessions.get(sessionId)
17
+ if (session === undefined) throw new WorkbenchError('session-not-found', 'session not found', 404)
18
+ const cwd = ctx.workingDirectory.get(session)
18
19
  if (!isAbsolute(cwd)) throw new WorkbenchError('cwd-invalid', 'session working directory is not absolute')
19
20
  return normalize(cwd)
20
21
  }
package/src/index.ts CHANGED
@@ -9,12 +9,12 @@ import type { HostContext } from './context.ts'
9
9
  import { codiconAsset, createEntry, cwdFor, deleteEntry, gitAction, gitStatus, listFiles, monacoAsset, pdfWorkerAsset, readDocument, renameEntry, saveDocument, searchWorkspace, uploadFile, workspacePreviewFile } from './host.ts'
10
10
  import { installTerminal } from './terminal.ts'
11
11
  import type { SearchRequest } from './types.ts'
12
- import { readBinaryBody, readBody, send, sendError, stringField, trusted, WorkbenchError } from './wire.ts'
12
+ import { readBinaryBody, readBody, send, sendError, stringField, authorize, WorkbenchError } from './wire.ts'
13
13
 
14
14
  export { WorkbenchError } from './wire.ts'
15
15
  export * from './types.ts'
16
16
  export const name = 'dsh-vscode-workbench'
17
- export const inject = ['webServer', 'sessions', 'webRuntime']
17
+ export const inject = ['webServer', 'sessions', 'connection', 'workingDirectory']
18
18
 
19
19
  async function dispatch(ctx: HostContext, method: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
20
20
  const sessionId = stringField(body, 'sessionId'); const cwd = cwdFor(ctx, sessionId)
@@ -37,7 +37,7 @@ export function apply(ctx: HostContext): void {
37
37
  installTerminal(ctx)
38
38
  ctx.effect(() => ctx.webServer.register({
39
39
  kind: 'exact', path: '/dsh-vscode/codicons/codicon.ttf', handler: async (req, res) => {
40
- if (!trusted(req, ctx.webRuntime.trustedHosts)) { res.writeHead(403); res.end(); return }
40
+ if (!authorize(req, res, ctx.connection)) return
41
41
  try {
42
42
  const asset = codiconAsset()
43
43
  res.writeHead(200, { 'content-type': asset.type, 'cache-control': 'public, max-age=31536000, immutable' })
@@ -48,7 +48,7 @@ export function apply(ctx: HostContext): void {
48
48
 
49
49
  ctx.effect(() => ctx.webServer.register({
50
50
  kind: 'exact', path: '/dsh-vscode/pdfjs/worker.mjs', handler: async (req, res) => {
51
- if (!trusted(req, ctx.webRuntime.trustedHosts)) { res.writeHead(403); res.end(); return }
51
+ if (!authorize(req, res, ctx.connection)) return
52
52
  try {
53
53
  const asset = pdfWorkerAsset()
54
54
  res.writeHead(200, { 'content-type': asset.type, 'cache-control': 'public, max-age=31536000, immutable' })
@@ -59,7 +59,7 @@ export function apply(ctx: HostContext): void {
59
59
 
60
60
  ctx.effect(() => ctx.webServer.register({
61
61
  kind: 'prefix', path: '/dsh-vscode/api', handler: async (req, res) => {
62
- if (!trusted(req, ctx.webRuntime.trustedHosts)) return send(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
62
+ if (!authorize(req, res, ctx.connection)) return
63
63
  if (req.method !== 'POST') return send(res, 405, { ok: false, error: { code: 'method', message: 'POST required' } })
64
64
  try {
65
65
  const controller = new AbortController()
@@ -73,7 +73,7 @@ export function apply(ctx: HostContext): void {
73
73
 
74
74
  ctx.effect(() => ctx.webServer.register({
75
75
  kind: 'exact', path: '/dsh-vscode/upload', handler: async (req, res) => {
76
- if (!trusted(req, ctx.webRuntime.trustedHosts)) return send(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
76
+ if (!authorize(req, res, ctx.connection)) return
77
77
  if (req.method !== 'POST') return send(res, 405, { ok: false, error: { code: 'method', message: 'POST required' } })
78
78
  try {
79
79
  const url = new URL(req.url ?? '/', 'http://dsh.local')
@@ -85,12 +85,12 @@ export function apply(ctx: HostContext): void {
85
85
  }), 'dsh-vscode-workbench: file upload')
86
86
 
87
87
  ctx.effect(() => ctx.webServer.register({
88
- kind:'prefix',path:'/dsh-vscode/file',handler:async(req,res)=>{if(!trusted(req,ctx.webRuntime.trustedHosts)){res.writeHead(403);res.end();return}try{const url=new URL(req.url??'/','http://dsh.local');const sessionId=url.searchParams.get('sessionId');const path=url.searchParams.get('path');if(sessionId===null||path===null)throw new WorkbenchError('bad-request','sessionId and path are required');const asset=await workspacePreviewFile(cwdFor(ctx,sessionId),path);res.writeHead(200,{'content-type':asset.type,'x-content-type-options':'nosniff','cache-control':'no-store'});res.end(await readFile(asset.path))}catch(error){sendError(res,error)}}
88
+ kind:'prefix',path:'/dsh-vscode/file',handler:async(req,res)=>{if(!authorize(req,res,ctx.connection))return;try{const url=new URL(req.url??'/','http://dsh.local');const sessionId=url.searchParams.get('sessionId');const path=url.searchParams.get('path');if(sessionId===null||path===null)throw new WorkbenchError('bad-request','sessionId and path are required');const asset=await workspacePreviewFile(cwdFor(ctx,sessionId),path);res.writeHead(200,{'content-type':asset.type,'x-content-type-options':'nosniff','cache-control':'no-store'});res.end(await readFile(asset.path))}catch(error){sendError(res,error)}}
89
89
  }), 'dsh-vscode-workbench: file preview')
90
90
 
91
91
  ctx.effect(() => ctx.webServer.register({
92
92
  kind: 'prefix', path: '/dsh-vscode/monaco', handler: async (req, res) => {
93
- if (!trusted(req, ctx.webRuntime.trustedHosts)) { res.writeHead(403); res.end(); return }
93
+ if (!authorize(req, res, ctx.connection)) return
94
94
  try {
95
95
  const raw = decodeURIComponent(new URL(req.url ?? '/', 'http://dsh.local').pathname.slice('/dsh-vscode/monaco/'.length))
96
96
  const asset = monacoAsset(raw)
@@ -103,11 +103,13 @@ export function apply(ctx: HostContext): void {
103
103
  const wss = new WebSocketServer({ noServer: true })
104
104
  ctx.effect(() => ctx.webServer.registerUpgrade({
105
105
  path: '/dsh-vscode/ws/files', handler(req, socket, head) {
106
- if (!trusted(req as IncomingMessage, ctx.webRuntime.trustedHosts)) { socket.destroy(); return }
106
+ if (ctx.connection.requestRejection(req) !== undefined) { socket.destroy(); return }
107
107
  wss.handleUpgrade(req as IncomingMessage, socket as Duplex, head, (ws) => {
108
108
  const sessionId = new URL(req.url ?? '/', 'http://dsh.local').searchParams.get('sessionId')
109
109
  if (sessionId === null) { ws.close(1008, 'sessionId required'); return }
110
- const workspace = cwdFor(ctx, sessionId)
110
+ let workspace: string
111
+ try { workspace = cwdFor(ctx, sessionId) }
112
+ catch { ws.close(1008, 'session workspace unavailable'); return }
111
113
  const watcher = chokidar.watch(workspace, { ignoreInitial: true, ignored: /(^|[/\\])(\.git|node_modules|\.next|\.nuxt|\.turbo|\.vite|coverage)([/\\]|$)/, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 25 } })
112
114
  let paths = new Set<string>(); let timer: ReturnType<typeof setTimeout> | undefined
113
115
  watcher.on('all', (_event, path) => {
package/src/terminal.ts CHANGED
@@ -8,7 +8,7 @@ import type { IPty } from 'node-pty'
8
8
  import { WebSocket, WebSocketServer } from 'ws'
9
9
  import type { HostContext } from './context.ts'
10
10
  import { cwdFor } from './host.ts'
11
- import { readBody, send, sendError } from './wire.ts'
11
+ import { authorize, readBody, send, sendError } from './wire.ts'
12
12
 
13
13
  export interface TerminalInfo { id: string; title: string; cwd: string; running: boolean; exitCode?: number; sequence: number }
14
14
  export interface TerminalOutput { sequence: number; data: string }
@@ -99,6 +99,7 @@ function terminalRequestAllowed(req: IncomingMessage): boolean {
99
99
  export function installTerminal(ctx: HostContext): void {
100
100
  const manager = new TerminalManager()
101
101
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-vscode-terminal/api', handler: async (req, res) => {
102
+ if (!authorize(req, res, ctx.connection)) return
102
103
  if (!terminalRequestAllowed(req)) return send(res, 403, { ok: false, error: { message: 'forbidden' } })
103
104
  if (req.method !== 'POST') return send(res, 405, { ok: false, error: { message: 'POST required' } })
104
105
  try {
@@ -119,13 +120,15 @@ export function installTerminal(ctx: HostContext): void {
119
120
 
120
121
  const webSocket = new WebSocketServer({ noServer: true })
121
122
  ctx.effect(() => ctx.webServer.registerUpgrade({ path: '/dsh-vscode-terminal/ws', handler(req, socket, head) {
122
- if (!terminalRequestAllowed(req as IncomingMessage)) { socket.destroy(); return }
123
+ if (ctx.connection.requestRejection(req) !== undefined || !terminalRequestAllowed(req)) { socket.destroy(); return }
123
124
  webSocket.handleUpgrade(req as IncomingMessage, socket as Duplex, head, connection => {
124
125
  const url = new URL(req.url ?? '/', 'http://dsh.local')
125
126
  const sessionId = url.searchParams.get('sessionId')
126
127
  const id = url.searchParams.get('id')
127
128
  if (sessionId === null || id === null) { connection.close(1008, 'sessionId and id required'); return }
128
- const handle = manager.get(cwdFor(ctx, sessionId), id)
129
+ let handle: TerminalHandle | undefined
130
+ try { handle = manager.get(cwdFor(ctx, sessionId), id) }
131
+ catch { connection.close(1008, 'session workspace unavailable'); return }
129
132
  if (handle === undefined) { connection.close(1008, 'terminal not found'); return }
130
133
  if (handle.replay !== '') connection.send(JSON.stringify({ type: 'replay', sequence: handle.info.sequence, data: handle.replay }))
131
134
  const listener = (output: TerminalOutput) => { if (connection.readyState === WebSocket.OPEN) connection.send(JSON.stringify({ type: 'output', ...output })) }
package/src/wire.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import type { WorkbenchConnection } from './context.ts'
2
3
 
3
4
  export class WorkbenchError extends Error {
4
5
  constructor(public code: string, message: string, public status = 400) { super(message) }
@@ -49,14 +50,11 @@ export function sendError(res: ServerResponse, error: unknown): void {
49
50
  send(res, known.status, { ok: false, error: { code: known.code, message: known.message } })
50
51
  }
51
52
 
52
- export function trusted(req: IncomingMessage, allowed: readonly string[]): boolean {
53
- const host = req.headers.host
54
- if (host === undefined || req.headers['sec-fetch-site'] === 'cross-site') return false
55
- let hostUrl: URL
56
- try { hostUrl = new URL(`http://${host}`) } catch { return false }
57
- const loopback = hostUrl.hostname === 'localhost' || hostUrl.hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(hostUrl.hostname)
58
- if (!loopback && !allowed.some(candidate => candidate === host || candidate === hostUrl.hostname)) return false
59
- const origin = req.headers.origin
60
- if (origin === undefined) return true
61
- try { return new URL(origin).host === hostUrl.host } catch { return false }
53
+ /** Reuse Harness authentication and its deployment-aware Host/Origin fence. */
54
+ export function authorize(req: IncomingMessage, res: ServerResponse, connection: WorkbenchConnection): boolean {
55
+ const rejection = connection.requestRejection(req)
56
+ if (rejection === undefined) return true
57
+ const code = rejection === 401 ? 'unauthorized' : 'forbidden'
58
+ send(res, rejection, { ok: false, error: { code, message: code } })
59
+ return false
62
60
  }