dsh-vscode-workbench 0.1.7 → 0.1.9

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.
@@ -0,0 +1,83 @@
1
+ import type { TerminalInfo } from '../terminal.ts'
2
+
3
+ export interface TerminalConnection {
4
+ write(data: string): void
5
+ resize(cols: number, rows: number): void
6
+ close(): void
7
+ subscribe(listener: (output: { sequence: number; data: string; replay?: boolean }) => void): () => void
8
+ }
9
+
10
+ export interface TerminalWorkspaceSnapshot { terminals: readonly TerminalInfo[]; loading: boolean; error?: string }
11
+
12
+ /** Browser client for the workbench's local terminal transport. */
13
+ export interface TerminalWorkspace {
14
+ refresh(): Promise<void>
15
+ create(title?: string): Promise<TerminalInfo>
16
+ close(id: string): Promise<void>
17
+ rename(id: string, title: string): Promise<void>
18
+ connect(id: string): TerminalConnection
19
+ getSnapshot(): TerminalWorkspaceSnapshot
20
+ subscribe(listener: () => void): () => void
21
+ }
22
+
23
+ async function request<T>(method: string, body: Record<string, unknown>): Promise<T> {
24
+ const response = await fetch(`/dsh-vscode-terminal/api/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
25
+ const text = await response.text()
26
+ let answer: { ok?: boolean; value?: T; error?: { message?: string } }
27
+ try { answer = JSON.parse(text) as { ok?: boolean; value?: T; error?: { message?: string } } } catch { throw new Error(`terminal request returned invalid JSON (${response.status})`) }
28
+ if (!response.ok || answer.ok !== true) throw new Error(answer.error?.message ?? 'terminal request failed')
29
+ return answer.value as T
30
+ }
31
+
32
+ class SocketConnection implements TerminalConnection {
33
+ private readonly socket: WebSocket
34
+ private readonly listeners = new Set<(output: { sequence: number; data: string; replay?: boolean }) => void>()
35
+ private queue: string[] = []
36
+
37
+ constructor(sessionId: string, id: string) {
38
+ const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
39
+ this.socket = new WebSocket(`${protocol}://${location.host}/dsh-vscode-terminal/ws?sessionId=${encodeURIComponent(sessionId)}&id=${encodeURIComponent(id)}`)
40
+ this.socket.onopen = () => { for (const value of this.queue) this.socket.send(value); this.queue = [] }
41
+ this.socket.onmessage = event => {
42
+ try {
43
+ const value = JSON.parse(String(event.data)) as { type?: unknown; sequence?: unknown; data?: unknown }
44
+ if (typeof value.sequence !== 'number' || typeof value.data !== 'string') return
45
+ for (const listener of this.listeners) listener({ sequence: value.sequence, data: value.data, replay: value.type === 'replay' })
46
+ } catch { /* Ignore malformed WebSocket messages from a disconnected server. */ }
47
+ }
48
+ }
49
+
50
+ private send(value: unknown): void { const text = JSON.stringify(value); if (this.socket.readyState === WebSocket.OPEN) this.socket.send(text); else this.queue.push(text) }
51
+ write(data: string): void { this.send({ type: 'input', data }) }
52
+ resize(cols: number, rows: number): void { this.send({ type: 'resize', cols, rows }) }
53
+ close(): void { this.socket.close() }
54
+ subscribe(listener: (output: { sequence: number; data: string; replay?: boolean }) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener) }
55
+ }
56
+
57
+ /** Identify automatic terminal-emulator replies that must not be echoed during replay. */
58
+ export function isTerminalProtocolReply(data: string): boolean {
59
+ return /^\x1b\](?:10|11|12);(?:rgb:[\da-f]{4}\/[\da-f]{4}\/[\da-f]{4}|#[\da-f]{6})(?:\x07|\x1b\\)$/i.test(data)
60
+ || /^\x1b\[\??[\d;]*[cR]$/.test(data)
61
+ }
62
+
63
+ /** Maintain one terminal collection for a DSH session. */
64
+ export class TerminalWorkspaceController implements TerminalWorkspace {
65
+ private snapshot: TerminalWorkspaceSnapshot = { terminals: [], loading: false }
66
+ private readonly listeners = new Set<() => void>()
67
+
68
+ constructor(private readonly sessionId: string) {}
69
+
70
+ async refresh(): Promise<void> {
71
+ this.set({ ...this.snapshot, loading: true, error: undefined })
72
+ try { this.set({ terminals: await request<TerminalInfo[]>('list', { sessionId: this.sessionId }), loading: false }) }
73
+ catch (error) { this.set({ ...this.snapshot, loading: false, error: error instanceof Error ? error.message : String(error) }) }
74
+ }
75
+
76
+ async create(title?: string): Promise<TerminalInfo> { const terminal = await request<TerminalInfo>('create', { sessionId: this.sessionId, ...(title === undefined ? {} : { title }) }); await this.refresh(); return terminal }
77
+ async close(id: string): Promise<void> { await request('close', { sessionId: this.sessionId, id }); await this.refresh() }
78
+ async rename(id: string, title: string): Promise<void> { await request('rename', { sessionId: this.sessionId, id, title }); await this.refresh() }
79
+ connect(id: string): TerminalConnection { return new SocketConnection(this.sessionId, id) }
80
+ getSnapshot(): TerminalWorkspaceSnapshot { return this.snapshot }
81
+ subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener) }
82
+ private set(snapshot: TerminalWorkspaceSnapshot): void { this.snapshot = snapshot; for (const listener of this.listeners) listener() }
83
+ }
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
  }
@@ -125,8 +126,16 @@ export async function deleteEntry(cwd: string, path: string): Promise<void> {
125
126
  }
126
127
 
127
128
  const IMAGE_TYPES:Record<string,string>={'.avif':'image/avif','.bmp':'image/bmp','.gif':'image/gif','.ico':'image/x-icon','.jpeg':'image/jpeg','.jpg':'image/jpeg','.png':'image/png','.svg':'image/svg+xml','.webp':'image/webp'}
129
+ const PREVIEW_TYPES:Record<string,string>={...IMAGE_TYPES,'.pdf':'application/pdf','.doc':'application/msword','.docx':'application/vnd.openxmlformats-officedocument.wordprocessingml.document','.odt':'application/vnd.oasis.opendocument.text'}
128
130
  /** Resolve an image inside the workspace for protected browser preview. */
129
131
  export async function workspaceImage(cwd:string,path:string):Promise<{path:string;type:string}>{const resolved=await workspacePath(cwd,path);const type=IMAGE_TYPES[extname(resolved.absolute).toLowerCase()];if(type===undefined)throw new WorkbenchError('unsupported-media','file is not a supported image',415);return {path:resolved.absolute,type}}
132
+ /** Resolve a supported preview file inside the workspace. */
133
+ export async function workspacePreviewFile(cwd:string,path:string):Promise<{path:string;type:string}>{const resolved=await workspacePath(cwd,path);const type=PREVIEW_TYPES[extname(resolved.absolute).toLowerCase()];if(type===undefined)throw new WorkbenchError('unsupported-media','file is not a supported preview format',415);return {path:resolved.absolute,type}}
134
+
135
+ /** Resolve the PDF.js worker shipped with this plugin's runtime dependency. */
136
+ export function pdfWorkerAsset(): { path: string; type: string } {
137
+ return { path: require.resolve('pdfjs-dist/legacy/build/pdf.worker.min.mjs'), type: 'text/javascript; charset=utf-8' }
138
+ }
130
139
 
131
140
  interface ProcessResult { stdout: string; stderr: string; code: number }
132
141
  export function run(command: string, args: string[], cwd: string, timeout = PROCESS_TIMEOUT, signal?: AbortSignal): Promise<ProcessResult> {
package/src/index.ts CHANGED
@@ -6,14 +6,15 @@ import type { Duplex } from 'node:stream'
6
6
  import chokidar from 'chokidar'
7
7
  import { WebSocket, WebSocketServer } from 'ws'
8
8
  import type { HostContext } from './context.ts'
9
- import { codiconAsset, createEntry, cwdFor, deleteEntry, gitAction, gitStatus, listFiles, monacoAsset, readDocument, renameEntry, saveDocument, searchWorkspace, uploadFile, workspaceImage } from './host.ts'
9
+ import { codiconAsset, createEntry, cwdFor, deleteEntry, gitAction, gitStatus, listFiles, monacoAsset, pdfWorkerAsset, readDocument, renameEntry, saveDocument, searchWorkspace, uploadFile, workspacePreviewFile } from './host.ts'
10
+ import { installTerminal } from './terminal.ts'
10
11
  import type { SearchRequest } from './types.ts'
11
- import { readBinaryBody, readBody, send, sendError, stringField, trusted, WorkbenchError } from './wire.ts'
12
+ import { readBinaryBody, readBody, send, sendError, stringField, authorize, WorkbenchError } from './wire.ts'
12
13
 
13
14
  export { WorkbenchError } from './wire.ts'
14
15
  export * from './types.ts'
15
16
  export const name = 'dsh-vscode-workbench'
16
- export const inject = ['webServer', 'sessions', 'webRuntime']
17
+ export const inject = ['webServer', 'sessions', 'connection', 'workingDirectory']
17
18
 
18
19
  async function dispatch(ctx: HostContext, method: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
19
20
  const sessionId = stringField(body, 'sessionId'); const cwd = cwdFor(ctx, sessionId)
@@ -33,9 +34,10 @@ async function dispatch(ctx: HostContext, method: string, body: Record<string, u
33
34
  }
34
35
 
35
36
  export function apply(ctx: HostContext): void {
37
+ installTerminal(ctx)
36
38
  ctx.effect(() => ctx.webServer.register({
37
39
  kind: 'exact', path: '/dsh-vscode/codicons/codicon.ttf', handler: async (req, res) => {
38
- if (!trusted(req, ctx.webRuntime.trustedHosts)) { res.writeHead(403); res.end(); return }
40
+ if (!authorize(req, res, ctx.connection)) return
39
41
  try {
40
42
  const asset = codiconAsset()
41
43
  res.writeHead(200, { 'content-type': asset.type, 'cache-control': 'public, max-age=31536000, immutable' })
@@ -44,9 +46,20 @@ export function apply(ctx: HostContext): void {
44
46
  },
45
47
  }), 'dsh-vscode-workbench: Codicon font')
46
48
 
49
+ ctx.effect(() => ctx.webServer.register({
50
+ kind: 'exact', path: '/dsh-vscode/pdfjs/worker.mjs', handler: async (req, res) => {
51
+ if (!authorize(req, res, ctx.connection)) return
52
+ try {
53
+ const asset = pdfWorkerAsset()
54
+ res.writeHead(200, { 'content-type': asset.type, 'cache-control': 'public, max-age=31536000, immutable' })
55
+ res.end(await readFile(asset.path))
56
+ } catch (error) { sendError(res, error) }
57
+ },
58
+ }), 'dsh-vscode-workbench: PDF.js worker')
59
+
47
60
  ctx.effect(() => ctx.webServer.register({
48
61
  kind: 'prefix', path: '/dsh-vscode/api', handler: async (req, res) => {
49
- 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
50
63
  if (req.method !== 'POST') return send(res, 405, { ok: false, error: { code: 'method', message: 'POST required' } })
51
64
  try {
52
65
  const controller = new AbortController()
@@ -60,7 +73,7 @@ export function apply(ctx: HostContext): void {
60
73
 
61
74
  ctx.effect(() => ctx.webServer.register({
62
75
  kind: 'exact', path: '/dsh-vscode/upload', handler: async (req, res) => {
63
- 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
64
77
  if (req.method !== 'POST') return send(res, 405, { ok: false, error: { code: 'method', message: 'POST required' } })
65
78
  try {
66
79
  const url = new URL(req.url ?? '/', 'http://dsh.local')
@@ -72,12 +85,12 @@ export function apply(ctx: HostContext): void {
72
85
  }), 'dsh-vscode-workbench: file upload')
73
86
 
74
87
  ctx.effect(() => ctx.webServer.register({
75
- 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 workspaceImage(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)}}
76
- }), 'dsh-vscode-workbench: image preview')
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
+ }), 'dsh-vscode-workbench: file preview')
77
90
 
78
91
  ctx.effect(() => ctx.webServer.register({
79
92
  kind: 'prefix', path: '/dsh-vscode/monaco', handler: async (req, res) => {
80
- if (!trusted(req, ctx.webRuntime.trustedHosts)) { res.writeHead(403); res.end(); return }
93
+ if (!authorize(req, res, ctx.connection)) return
81
94
  try {
82
95
  const raw = decodeURIComponent(new URL(req.url ?? '/', 'http://dsh.local').pathname.slice('/dsh-vscode/monaco/'.length))
83
96
  const asset = monacoAsset(raw)
@@ -90,11 +103,13 @@ export function apply(ctx: HostContext): void {
90
103
  const wss = new WebSocketServer({ noServer: true })
91
104
  ctx.effect(() => ctx.webServer.registerUpgrade({
92
105
  path: '/dsh-vscode/ws/files', handler(req, socket, head) {
93
- if (!trusted(req as IncomingMessage, ctx.webRuntime.trustedHosts)) { socket.destroy(); return }
106
+ if (ctx.connection.requestRejection(req) !== undefined) { socket.destroy(); return }
94
107
  wss.handleUpgrade(req as IncomingMessage, socket as Duplex, head, (ws) => {
95
108
  const sessionId = new URL(req.url ?? '/', 'http://dsh.local').searchParams.get('sessionId')
96
109
  if (sessionId === null) { ws.close(1008, 'sessionId required'); return }
97
- const workspace = cwdFor(ctx, sessionId)
110
+ let workspace: string
111
+ try { workspace = cwdFor(ctx, sessionId) }
112
+ catch { ws.close(1008, 'session workspace unavailable'); return }
98
113
  const watcher = chokidar.watch(workspace, { ignoreInitial: true, ignored: /(^|[/\\])(\.git|node_modules|\.next|\.nuxt|\.turbo|\.vite|coverage)([/\\]|$)/, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 25 } })
99
114
  let paths = new Set<string>(); let timer: ReturnType<typeof setTimeout> | undefined
100
115
  watcher.on('all', (_event, path) => {
@@ -0,0 +1,148 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { realpathSync } from 'node:fs'
3
+ import { basename } from 'node:path'
4
+ import type { IncomingMessage } from 'node:http'
5
+ import type { Duplex } from 'node:stream'
6
+ import * as pty from 'node-pty'
7
+ import type { IPty } from 'node-pty'
8
+ import { WebSocket, WebSocketServer } from 'ws'
9
+ import type { HostContext } from './context.ts'
10
+ import { cwdFor } from './host.ts'
11
+ import { authorize, readBody, send, sendError } from './wire.ts'
12
+
13
+ export interface TerminalInfo { id: string; title: string; cwd: string; running: boolean; exitCode?: number; sequence: number }
14
+ export interface TerminalOutput { sequence: number; data: string }
15
+
16
+ interface TerminalHandle { info: TerminalInfo; process: IPty; replay: string; listeners: Set<(output: TerminalOutput) => void> }
17
+ const SENSITIVE = /(api[_-]?key|token|credential|secret|password|passwd|private[_-]?key|^dsh_.*(?:key|token|secret))/i
18
+ const MAX_TERMINALS = 8
19
+ const REPLAY_BYTES = 2 * 1024 * 1024
20
+
21
+ /** Remove credentials while retaining ordinary interactive-shell variables. */
22
+ export function sanitizedTerminalEnv(env: NodeJS.ProcessEnv): Record<string, string> {
23
+ return Object.fromEntries(Object.entries(env).filter(([key, value]) => value !== undefined && !SENSITIVE.test(key)) as Array<[string, string]>)
24
+ }
25
+
26
+ /** Select the user's interactive shell with platform-specific fallbacks. */
27
+ export function defaultTerminalShell(platform = process.platform, env = process.env): { shell: string; args: string[] } {
28
+ if (platform === 'win32') return { shell: env.COMSPEC?.toLowerCase().includes('powershell') ? env.COMSPEC : 'powershell.exe', args: ['-NoLogo'] }
29
+ return { shell: env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/bash'), args: ['-l'] }
30
+ }
31
+
32
+ /** Own interactive terminals by their canonical workspace directory. */
33
+ export class TerminalManager {
34
+ private readonly workspaces = new Map<string, Map<string, TerminalHandle>>()
35
+
36
+ private workspace(cwd: string): string { return realpathSync(cwd) }
37
+
38
+ list(cwd: string): TerminalInfo[] { return [...(this.workspaces.get(this.workspace(cwd))?.values() ?? [])].map(handle => ({ ...handle.info })) }
39
+
40
+ create(cwd: string, title?: string): TerminalInfo {
41
+ const key = this.workspace(cwd)
42
+ const terminals = this.workspaces.get(key) ?? new Map<string, TerminalHandle>()
43
+ this.workspaces.set(key, terminals)
44
+ if (terminals.size >= MAX_TERMINALS) throw new Error(`terminal limit reached (${MAX_TERMINALS})`)
45
+ const { shell, args } = defaultTerminalShell()
46
+ const child = pty.spawn(shell, args, { name: 'xterm-256color', cols: 80, rows: 24, cwd: key, env: sanitizedTerminalEnv(process.env) })
47
+ const info: TerminalInfo = { id: randomUUID(), title: title || basename(shell), cwd: key, running: true, sequence: 0 }
48
+ const handle: TerminalHandle = { info, process: child, replay: '', listeners: new Set() }
49
+ terminals.set(info.id, handle)
50
+ child.onData(data => {
51
+ info.sequence++
52
+ handle.replay += data
53
+ if (Buffer.byteLength(handle.replay) > REPLAY_BYTES) handle.replay = handle.replay.slice(-REPLAY_BYTES)
54
+ for (const listener of handle.listeners) listener({ sequence: info.sequence, data })
55
+ })
56
+ child.onExit(({ exitCode }) => {
57
+ info.running = false
58
+ info.exitCode = exitCode
59
+ info.sequence++
60
+ const data = `\r\n[process exited with code ${exitCode}]\r\n`
61
+ for (const listener of handle.listeners) listener({ sequence: info.sequence, data })
62
+ })
63
+ return { ...info }
64
+ }
65
+
66
+ get(cwd: string, id: string): TerminalHandle | undefined { return this.workspaces.get(this.workspace(cwd))?.get(id) }
67
+
68
+ close(cwd: string, id: string): void {
69
+ const key = this.workspace(cwd)
70
+ const terminals = this.workspaces.get(key)
71
+ const handle = terminals?.get(id)
72
+ if (handle === undefined || terminals === undefined) return
73
+ terminals.delete(id)
74
+ try { handle.process.kill() } catch { /* The shell may already have exited. */ }
75
+ if (terminals.size === 0) this.workspaces.delete(key)
76
+ }
77
+
78
+ rename(cwd: string, id: string, title: string): TerminalInfo {
79
+ const handle = this.get(cwd, id)
80
+ if (handle === undefined) throw new Error('terminal not found')
81
+ handle.info.title = title
82
+ return { ...handle.info }
83
+ }
84
+
85
+ dispose(): void { for (const [cwd, terminals] of this.workspaces) for (const id of terminals.keys()) this.close(cwd, id) }
86
+ }
87
+
88
+ function terminalRequestAllowed(req: IncomingMessage): boolean {
89
+ const host = req.headers.host
90
+ if (host === undefined || req.headers['sec-fetch-site'] === 'cross-site') return false
91
+ let hostname: string
92
+ try { hostname = new URL(`http://${host}`).hostname } catch { return false }
93
+ if (hostname !== 'localhost' && hostname !== '[::1]' && !hostname.startsWith('127.')) return false
94
+ const origin = req.headers.origin
95
+ try { return origin === undefined || new URL(origin).host === host } catch { return false }
96
+ }
97
+
98
+ /** Register the private local terminal API and WebSocket transport. */
99
+ export function installTerminal(ctx: HostContext): void {
100
+ const manager = new TerminalManager()
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
103
+ if (!terminalRequestAllowed(req)) return send(res, 403, { ok: false, error: { message: 'forbidden' } })
104
+ if (req.method !== 'POST') return send(res, 405, { ok: false, error: { message: 'POST required' } })
105
+ try {
106
+ const body = await readBody(req)
107
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
108
+ if (sessionId === '') throw new Error('sessionId required')
109
+ const cwd = cwdFor(ctx, sessionId)
110
+ const method = new URL(req.url ?? '/', 'http://dsh.local').pathname.slice('/dsh-vscode-terminal/api/'.length)
111
+ let value: unknown
112
+ if (method === 'list') value = manager.list(cwd)
113
+ else if (method === 'create') value = manager.create(cwd, typeof body.title === 'string' ? body.title : undefined)
114
+ else if (method === 'close') { manager.close(cwd, typeof body.id === 'string' ? body.id : ''); value = manager.list(cwd) }
115
+ else if (method === 'rename') value = manager.rename(cwd, typeof body.id === 'string' ? body.id : '', typeof body.title === 'string' ? body.title : '')
116
+ else throw new Error('unknown terminal method')
117
+ send(res, 200, { ok: true, value })
118
+ } catch (error) { sendError(res, error) }
119
+ } }), 'dsh-vscode-workbench: terminal API')
120
+
121
+ const webSocket = new WebSocketServer({ noServer: true })
122
+ ctx.effect(() => ctx.webServer.registerUpgrade({ path: '/dsh-vscode-terminal/ws', handler(req, socket, head) {
123
+ if (ctx.connection.requestRejection(req) !== undefined || !terminalRequestAllowed(req)) { socket.destroy(); return }
124
+ webSocket.handleUpgrade(req as IncomingMessage, socket as Duplex, head, connection => {
125
+ const url = new URL(req.url ?? '/', 'http://dsh.local')
126
+ const sessionId = url.searchParams.get('sessionId')
127
+ const id = url.searchParams.get('id')
128
+ if (sessionId === null || id === null) { connection.close(1008, 'sessionId and id required'); return }
129
+ let handle: TerminalHandle | undefined
130
+ try { handle = manager.get(cwdFor(ctx, sessionId), id) }
131
+ catch { connection.close(1008, 'session workspace unavailable'); return }
132
+ if (handle === undefined) { connection.close(1008, 'terminal not found'); return }
133
+ if (handle.replay !== '') connection.send(JSON.stringify({ type: 'replay', sequence: handle.info.sequence, data: handle.replay }))
134
+ const listener = (output: TerminalOutput) => { if (connection.readyState === WebSocket.OPEN) connection.send(JSON.stringify({ type: 'output', ...output })) }
135
+ handle.listeners.add(listener)
136
+ connection.on('message', raw => {
137
+ const text = raw.toString()
138
+ try {
139
+ const message: unknown = JSON.parse(text)
140
+ if (message !== null && typeof message === 'object' && 'type' in message && message.type === 'input' && 'data' in message && typeof message.data === 'string') handle.process.write(message.data)
141
+ else if (message !== null && typeof message === 'object' && 'type' in message && message.type === 'resize' && 'cols' in message && 'rows' in message && typeof message.cols === 'number' && typeof message.rows === 'number') handle.process.resize(Math.max(2, Math.floor(message.cols)), Math.max(2, Math.floor(message.rows)))
142
+ } catch { handle.process.write(text) }
143
+ })
144
+ connection.on('close', () => handle.listeners.delete(listener))
145
+ })
146
+ } }), 'dsh-vscode-workbench: terminal WebSocket')
147
+ ctx.effect(() => () => { webSocket.close(); manager.dispose() }, 'dsh-vscode-workbench: terminal teardown')
148
+ }
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
  }