490 lines
21 KiB
TypeScript
490 lines
21 KiB
TypeScript
/**
|
|
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
|
|
* read/write, git), the /sidebar/file media route (images), and the terminal
|
|
* WebSocket upgrade. Every route passes the same browser-trust fence as the
|
|
* /api gateway — Host-header loopback or the connection row's `trustedHosts`
|
|
* (the `dsh web` launcher derives LAN IP literals per boot) — with the
|
|
* trustedHosts read live from the connection loader row so the fence never
|
|
* drifts from the deployment's.
|
|
*
|
|
* All operations are conversation-scoped: requests carry a sessionId, the
|
|
* session's authoritative cwd comes from the session store, and terminal
|
|
* processes are keyed by session.
|
|
*/
|
|
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
import { basename, dirname, extname } from 'node:path'
|
|
import type { IncomingMessage } from 'node:http'
|
|
import { WebSocket, WebSocketServer } from 'ws'
|
|
import type { Context } from './context-types.ts'
|
|
import {
|
|
Config,
|
|
PrefsSchema,
|
|
resolveSidebarConfig,
|
|
SIDEBAR_PREFS_NS,
|
|
type ResolvedSidebarConfig,
|
|
type SidebarConfig,
|
|
} from './config.ts'
|
|
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
|
import { isTrustedApiRequest } from './trust-fence.ts'
|
|
import * as git from './git.ts'
|
|
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
|
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
|
|
|
export { Config }
|
|
export type { SidebarConfig, ResolvedSidebarConfig }
|
|
|
|
/** Plugin identity for cordis.yml rows. */
|
|
export const name = 'dsh-better-sidebar'
|
|
|
|
/** Services required before mounting: the webserver routes, the session store, and the loader's connection row. */
|
|
export const inject = ['httpServer', 'sessions', 'loader']
|
|
|
|
/** Content types for the media route, by extension. */
|
|
const MEDIA_TYPES: Record<string, string> = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.webp': 'image/webp',
|
|
'.svg': 'image/svg+xml',
|
|
'.bmp': 'image/bmp',
|
|
'.ico': 'image/x-icon',
|
|
'.avif': 'image/avif',
|
|
'.pdf': 'application/pdf',
|
|
}
|
|
|
|
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
|
|
export function mediaTypeForPath(path: string): string {
|
|
return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
|
|
}
|
|
|
|
/** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
|
|
function trustedHostsOf(ctx: Context): string[] {
|
|
for (const entry of ctx.loader.entries()) {
|
|
if (entry.options.name === 'connection') {
|
|
const config = entry.options.config as { trustedHosts?: string[] } | undefined
|
|
return config?.trustedHosts ?? []
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
|
|
/**
|
|
* Resolve a session's authoritative working directory. The attached session
|
|
* header wins; while the session is still hydrating from persistence (the
|
|
* web client attaches the current conversation a moment after page load, so
|
|
* the very first sidebar requests can arrive detached) the caller's own
|
|
* list-summary cwd is used; the process cwd is the last resort (blank
|
|
* sessions have no cwd anywhere yet). Never throws for a missing cwd, so
|
|
* explorer/git/terminal work from first paint instead of surfacing
|
|
* "session ... has no working directory".
|
|
*/
|
|
function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): string {
|
|
const session = ctx.sessions.get(sessionId)
|
|
const headerCwd = session?.header.cwd
|
|
if (headerCwd !== undefined && headerCwd !== '') return headerCwd
|
|
if (clientCwd !== undefined && clientCwd !== '') {
|
|
try {
|
|
return requireAbsolute(clientCwd)
|
|
} catch {
|
|
throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
|
|
}
|
|
}
|
|
return process.cwd()
|
|
}
|
|
|
|
/** Text read of a file with the size cap; binary detection via NUL probe. */
|
|
async function readText(path: string, readLimit: number): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> {
|
|
const info = await stat(path).catch((error: unknown) => {
|
|
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
})
|
|
if (info.isDirectory()) {
|
|
throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
|
|
}
|
|
const size = info.size
|
|
const truncated = size > readLimit
|
|
const handle = await open(path, 'r').catch((error: unknown) => {
|
|
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
})
|
|
try {
|
|
const buffer = Buffer.alloc(Math.min(size, readLimit))
|
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
|
|
const slice = buffer.subarray(0, bytesRead)
|
|
const binary = slice.includes(0)
|
|
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size }
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
}
|
|
|
|
/** One API method dispatch table entry. */
|
|
type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
|
|
|
|
/**
|
|
* The live face of the side card settings namespace, bound to the settings
|
|
* service when it is mounted. The DSH settings RPC domain only serves
|
|
* allowlisted namespaces (api-proxy exposedNamespaces), so the client reads
|
|
* and writes THIS namespace through the plugin's own fenced /sidebar routes,
|
|
* which call the seam in-process — no configuration-client gate involved.
|
|
*/
|
|
export interface SidebarSettingsFace {
|
|
/** The current resolved value + revision (undefined while the settings service is absent). */
|
|
get(): { value?: unknown; revision?: number }
|
|
/** Merge a patch (revision-guarded) and return the fresh resolved view. */
|
|
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
|
|
}
|
|
|
|
/** Build the API method table bound to the plugin context, pty manager, and resolved config. */
|
|
function buildApi(
|
|
ctx: Context,
|
|
ptyManager: PtyManager,
|
|
resolved: ResolvedSidebarConfig,
|
|
getSettings: () => SidebarSettingsFace | undefined,
|
|
): Record<string, ApiMethod> {
|
|
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
|
|
const sessionId = requireString(payload, 'sessionId')
|
|
const record = payload as { cwd?: unknown } | null
|
|
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
|
|
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
|
|
}
|
|
return {
|
|
'session.cwd': (payload) => {
|
|
const { sessionId, cwd } = cwdOf(payload)
|
|
return { sessionId, cwd, root: rootLabel(cwd), parent: parentOf(cwd) ?? null }
|
|
},
|
|
'fs.tree': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
const record = payload as { path?: unknown }
|
|
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
|
|
return listDirectory(target, resolved.listLimit)
|
|
},
|
|
'fs.read': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
const path = requireAbsolute(requireString(payload, 'path'))
|
|
const { content, truncated, binary, size } = await readText(path, resolved.readLimit)
|
|
if (binary) return { kind: 'binary', size, truncated }
|
|
return { kind: 'text', content, truncated }
|
|
},
|
|
'fs.write': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
const path = requireAbsolute(requireString(payload, 'path'))
|
|
const content = requireString(payload, 'content')
|
|
const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`
|
|
try {
|
|
await mkdir(dirname(path), { recursive: true })
|
|
await writeFile(tmp, content, 'utf8')
|
|
await rename(tmp, path)
|
|
} catch (error) {
|
|
await rm(tmp, { force: true }).catch(() => {})
|
|
throw new SidebarError('fs-error', `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
}
|
|
return { ok: true }
|
|
},
|
|
'git.status': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
return git.status(cwd)
|
|
},
|
|
'git.diff': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
const record = payload as { path?: unknown; staged?: unknown }
|
|
const path = record.path === undefined ? undefined : requireAbsolute(requireString(payload, 'path'))
|
|
return { diff: await git.diff(cwd, path, record.staged === true) }
|
|
},
|
|
'git.stage': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
const record = payload as { path?: unknown }
|
|
const path = record.path === undefined ? undefined : requireString(payload, 'path')
|
|
await git.stage(cwd, path)
|
|
return { ok: true }
|
|
},
|
|
'git.unstage': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
const record = payload as { path?: unknown }
|
|
const path = record.path === undefined ? undefined : requireString(payload, 'path')
|
|
await git.unstage(cwd, path)
|
|
return { ok: true }
|
|
},
|
|
'git.commit': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
const message = requireString(payload, 'message')
|
|
await git.commit(cwd, message)
|
|
return { ok: true }
|
|
},
|
|
'git.branch': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
return git.branches(cwd)
|
|
},
|
|
'git.checkout': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
await git.checkout(cwd, requireString(payload, 'branch'))
|
|
return { ok: true }
|
|
},
|
|
'git.log': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
return git.log(cwd)
|
|
},
|
|
'git.show': async (payload) => {
|
|
const { cwd } = cwdOf(payload)
|
|
const path = requireAbsolute(requireString(payload, 'path'))
|
|
const rev = requireString(payload, 'rev')
|
|
return { content: await git.show(cwd, rev, path) }
|
|
},
|
|
// Release a terminal immediately. The WebSocket close frame already does
|
|
// this while the socket is open; this route covers the tab-close that
|
|
// happens while the socket is down (reconnect loop), so a closed tab can
|
|
// never hold the per-session quota until the reconnect grace expires.
|
|
'pty.close': (payload) => {
|
|
const sessionId = requireString(payload, 'sessionId')
|
|
const tab = requireString(payload, 'tab')
|
|
ptyManager.close(`${sessionId}:${tab}`)
|
|
return { ok: true }
|
|
},
|
|
// The side card preferences. The settings service is optional in the
|
|
// composition; while absent the routes report undefined and the client
|
|
// keeps the schema defaults. Writes are revision-guarded: a stale editor
|
|
// is refused with settings-conflict so a concurrent change is never
|
|
// silently overwritten (mirror of the settings seam's own guard).
|
|
'settings.get': () => {
|
|
const settings = getSettings()
|
|
return settings?.get() ?? { value: undefined, revision: undefined }
|
|
},
|
|
'settings.update': async (payload) => {
|
|
const settings = getSettings()
|
|
if (settings === undefined) {
|
|
throw new SidebarError('settings-rejected', 'the settings service is not mounted in this deployment', 503)
|
|
}
|
|
const record = payload as { patch?: unknown; expectedRevision?: unknown } | null
|
|
const patch = record?.patch
|
|
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
throw new SidebarError('bad-request', 'patch must be a plain object')
|
|
}
|
|
const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined
|
|
try {
|
|
return await settings.update(patch as Record<string, unknown>, expectedRevision)
|
|
} catch (error) {
|
|
if (error instanceof SettingsConflictError) {
|
|
throw new SidebarError('settings-conflict', error.message, 409)
|
|
}
|
|
throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Plugin body: mount the fenced routes and the pty lifecycle.
|
|
* @param ctx - host plugin context (httpServer, sessions, loader).
|
|
* @param config - deployment-provided limits; the Loader validates against
|
|
* {@link Config} and fills defaults, direct callers get them from
|
|
* {@link resolveSidebarConfig}.
|
|
*/
|
|
export function apply(ctx: Context, config?: SidebarConfig): void {
|
|
// pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
|
|
// restore it before any terminal can spawn (idempotent).
|
|
ensureSpawnHelper()
|
|
const resolved = resolveSidebarConfig(config)
|
|
const trustedHosts = trustedHostsOf(ctx)
|
|
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
|
|
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
|
|
|
|
// ── User-facing "Side card" preferences ──────────────────────────────────
|
|
// Register the namespace with the settings provider so the Settings page
|
|
// (client half) can render and persist the new-conversation defaults. The
|
|
// DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to
|
|
// configuration clients, so the client reaches this namespace through the
|
|
// plugin's own fenced routes below ('settings.get'/'settings.update'),
|
|
// which call the seam in-process. Deployments without a settings service
|
|
// simply never fill the face and the client falls back to the defaults.
|
|
let settingsFace: SidebarSettingsFace | undefined
|
|
ctx.inject(['settings'], (sctx) => {
|
|
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
|
|
sctx.settings.register(ns, PrefsSchema)
|
|
const viewOf = (): { value?: unknown; revision?: number } => {
|
|
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
|
|
return descriptor === undefined
|
|
? { value: undefined, revision: undefined }
|
|
: { value: descriptor.value, revision: descriptor.revision }
|
|
}
|
|
settingsFace = {
|
|
get: viewOf,
|
|
update: async (patch, expectedRevision) => {
|
|
await sctx.settings.update(ns, patch, expectedRevision)
|
|
return viewOf()
|
|
},
|
|
}
|
|
})
|
|
|
|
// ── JSON API ────────────────────────────────────────────────────────────
|
|
const api = buildApi(ctx, ptyManager, resolved, () => settingsFace)
|
|
ctx.effect(() => ctx.httpServer.register({
|
|
kind: 'prefix',
|
|
path: '/sidebar/api',
|
|
handler: async (req, res) => {
|
|
if (!fence(req)) {
|
|
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
return
|
|
}
|
|
if (req.method !== 'POST') {
|
|
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
|
return
|
|
}
|
|
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
const method = pathname.startsWith('/sidebar/api/') ? pathname.slice('/sidebar/api/'.length) : undefined
|
|
if (method === undefined || method.includes('/')) {
|
|
writeError(res, new SidebarError('not-found', 'unknown sidebar API method', 404))
|
|
return
|
|
}
|
|
try {
|
|
const payload = await readJsonBody(req)
|
|
const handler = api[method]
|
|
if (handler === undefined) {
|
|
throw new SidebarError('not-found', `unknown sidebar API method "${method}"`, 404)
|
|
}
|
|
writeOk(res, await handler(payload))
|
|
} catch (error) {
|
|
writeError(res, error)
|
|
}
|
|
},
|
|
}), 'dsh-better-sidebar: /sidebar/api routes')
|
|
|
|
// ── Media route (images for the editor) ─────────────────────────────────
|
|
ctx.effect(() => ctx.httpServer.register({
|
|
kind: 'prefix',
|
|
path: '/sidebar/file',
|
|
handler: async (req, res) => {
|
|
if (!fence(req)) {
|
|
res.writeHead(403)
|
|
res.end('forbidden')
|
|
return
|
|
}
|
|
if (req.method !== 'GET') {
|
|
res.writeHead(405)
|
|
res.end()
|
|
return
|
|
}
|
|
try {
|
|
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
const sessionId = url.searchParams.get('sessionId')
|
|
const raw = url.searchParams.get('path')
|
|
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
|
|
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
|
const path = requireAbsolute(raw)
|
|
if (!isWithin(cwd, path)) {
|
|
// Only files under the session cwd are served as media (the editor
|
|
// opens images from the explorer; produced files go through read).
|
|
// isWithin (not a raw startsWith) so case-mismatched Windows paths
|
|
// and mixed separators cannot be misclassified.
|
|
throw new SidebarError('fs-error', 'media path outside the session working directory', 403)
|
|
}
|
|
const info = await stat(path)
|
|
if (!info.isFile() || info.size > resolved.mediaLimit) {
|
|
throw new SidebarError('fs-error', 'not a file or too large', 400)
|
|
}
|
|
const type = mediaTypeForPath(path)
|
|
const body = await readFile(path)
|
|
// Raw bytes either way (binary-safe); ?download=1 switches the
|
|
// disposition so the browser saves the file instead of showing it.
|
|
const headers: Record<string, string> = { 'content-type': type, 'cache-control': 'no-cache' }
|
|
if (url.searchParams.get('download') === '1') {
|
|
headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`
|
|
}
|
|
res.writeHead(200, headers)
|
|
res.end(body)
|
|
} catch (error) {
|
|
writeError(res, error)
|
|
}
|
|
},
|
|
}), 'dsh-better-sidebar: /sidebar/file media route')
|
|
|
|
// ── Terminal WebSocket ──────────────────────────────────────────────────
|
|
const wss = new WebSocketServer({ noServer: true })
|
|
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
|
path: '/sidebar/ws/terminal',
|
|
handler: (req, socket, head) => {
|
|
if (!fence(req)) {
|
|
socket.destroy()
|
|
return
|
|
}
|
|
wss.handleUpgrade(req, socket, head, (ws) => { void attachTerminal(ctx, ptyManager, ws, req, resolved) })
|
|
},
|
|
}), 'dsh-better-sidebar: terminal WebSocket')
|
|
|
|
ctx.effect(() => () => {
|
|
ptyManager.disposeAll()
|
|
wss.close()
|
|
}, 'dsh-better-sidebar: teardown')
|
|
}
|
|
|
|
/** Wire one terminal socket to its pty: replay transcript, pump both ways. */
|
|
async function attachTerminal(
|
|
ctx: Context,
|
|
ptyManager: PtyManager,
|
|
ws: WebSocket,
|
|
req: IncomingMessage,
|
|
resolved: ResolvedSidebarConfig,
|
|
): Promise<void> {
|
|
try {
|
|
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
const sessionId = url.searchParams.get('sessionId')
|
|
const tabId = url.searchParams.get('tab')
|
|
if (sessionId === null || tabId === null) {
|
|
ws.close(1008, 'sessionId and tab are required')
|
|
return
|
|
}
|
|
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
|
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
|
|
// Replay the transcript, then follow live output.
|
|
if (handle.transcript !== '') ws.send(handle.transcript)
|
|
const onData = (data: string): void => {
|
|
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
|
|
ws.send(data)
|
|
}
|
|
}
|
|
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
|
|
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
|
|
}
|
|
const dataSub = handle.pty.onData(onData)
|
|
const exitSub = handle.pty.onExit(onExit)
|
|
ws.on('message', (data) => {
|
|
const text = data.toString('utf8')
|
|
// Control frames are JSON with a known shape; anything else (including
|
|
// JSON that is not a recognized control) is terminal input, verbatim.
|
|
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
|
|
try {
|
|
const parsed: unknown = JSON.parse(text)
|
|
if (parsed !== null && typeof parsed === 'object') {
|
|
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
|
|
}
|
|
} catch {
|
|
// Not JSON: terminal input.
|
|
}
|
|
if (control !== null && control.type === 'close') {
|
|
// The owning tab was closed: release the quota immediately.
|
|
ptyManager.scheduleClose(handle.key, 0)
|
|
return
|
|
}
|
|
if (handle.exited) return
|
|
if (
|
|
control !== null
|
|
&& control.type === 'resize'
|
|
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
|
) {
|
|
handle.pty.resize(Math.max(2, Math.floor(control.cols)), Math.max(2, Math.floor(control.rows)))
|
|
} else {
|
|
handle.pty.write(text)
|
|
}
|
|
})
|
|
ws.on('close', () => {
|
|
dataSub.dispose()
|
|
exitSub.dispose()
|
|
// A bare socket drop (refresh, tab switch) leaves the process alive
|
|
// for a grace period so a quick reconnect keeps it; the reconnect's
|
|
// open() cancels the pending close.
|
|
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
|
|
})
|
|
} catch (error) {
|
|
ws.close(1011, error instanceof Error ? error.message : String(error))
|
|
}
|
|
}
|