467 lines
20 KiB
TypeScript
467 lines
20 KiB
TypeScript
/**
|
|
* Workspace file service: read-only file previews, workspace directory
|
|
* listings, and the filesystem-observation change feed, exposed as
|
|
* `workspaceFiles`.
|
|
*
|
|
* File reads follow the composed filesystem's read access, including paths
|
|
* outside the workspace. The selected Session header supplies the base for
|
|
* relative paths, with the sandbox policy root as its no-cwd fallback, not a
|
|
* read-containment restriction. Directory listings and change observations
|
|
* remain workspace-scoped. File-kind checks and configured read caps apply to
|
|
* every preview; this service exposes no mutations.
|
|
*
|
|
* A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it
|
|
* goes, so the file is read only up to the first character past the page and
|
|
* never held whole in memory; the NUL scan runs on the page itself.
|
|
*
|
|
* This is NOT modelled on `session.openWorkspacePath`. That endpoint hands a
|
|
* path to the local opener and leaves the effect on the machine; this one sends
|
|
* file content across the wire, which is a different level of exposure.
|
|
*/
|
|
|
|
import { posix, win32 } from 'node:path'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import z from '@deepseek-ai/schemastery'
|
|
import type {} from '@deepseek-ai/dsh-fs'
|
|
import type { FsDirEntry, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
|
|
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
|
import type {} from '@deepseek-ai/dsh-session'
|
|
import type {} from '@deepseek-ai/dsh-session-persistence'
|
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
import { Remote, RemoteError, TypertRemoteService, type TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
|
|
import { WorkspaceChangeFeed } from './changes.ts'
|
|
import type {
|
|
WorkspaceByteRange,
|
|
WorkspaceByteReadOptions,
|
|
WorkspaceDirectoryEntry,
|
|
WorkspaceDirectoryListing,
|
|
WorkspaceFileBytes,
|
|
WorkspaceFileRange,
|
|
WorkspaceFileStat,
|
|
WorkspaceFileText,
|
|
WorkspaceFileWatchFrame,
|
|
} from './types.ts'
|
|
|
|
export type * from './types.ts'
|
|
|
|
declare module '@deepseek-ai/cordis' {
|
|
interface Context {
|
|
/** Host owner of the `workspaceFiles` Remote namespace. */
|
|
workspaceFiles: WorkspaceFiles
|
|
}
|
|
}
|
|
|
|
/** Header-derived file resolution context for one Session identity. */
|
|
export interface WorkspaceFileScope {
|
|
/** Session identity received on the wire. */
|
|
readonly sessionId: SessionId
|
|
/** Session workspace root, or the deployment fallback when its header has no cwd. */
|
|
readonly workspaceRoot: string
|
|
}
|
|
|
|
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
interface TypertLookupMap {
|
|
/** Resolve a Session id to its workspace root without loading its event body or activating an Agent. */
|
|
workspaceFileScope: TypertLookup<WorkspaceFileScope, SessionId>
|
|
}
|
|
}
|
|
|
|
/** Deployment caps on one page or one listing. */
|
|
export interface Config {
|
|
/**
|
|
* Inclusive byte cap on one page's text and on one byte window.
|
|
*
|
|
* A page above this fails; it is not shortened, because a silently cut page
|
|
* reads as the whole page. A byte window asking for more is refused the same
|
|
* way. The file itself has no size cap: a caller pages through it.
|
|
*/
|
|
readonly maxBytes: number
|
|
/** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */
|
|
readonly maxFileBytes: number
|
|
/** Default and largest page size in lines; a request asking for more is refused. */
|
|
readonly maxLines: number
|
|
/** Cap on returned directory entries; the rest is dropped and reported cut. */
|
|
readonly maxEntries: number
|
|
}
|
|
|
|
/** One page cut from a decoded text stream. */
|
|
interface Page {
|
|
readonly text: string
|
|
/** Lines in `text`; `0` for a page past the last line. */
|
|
readonly lines: number
|
|
readonly eof: boolean
|
|
}
|
|
|
|
/** The byte text never carries: its presence marks a page as binary. */
|
|
const NUL = String.fromCharCode(0)
|
|
|
|
/** Refuse anything the wire schema admits as a number but a window cannot use: only safe integers index a file. */
|
|
function integerAtLeast(value: number, min: number, name: string): number {
|
|
if (!Number.isSafeInteger(value) || value < min) {
|
|
throw new RemoteError('gateway/bad-request', `${name} must be a safe integer of at least ${min}`, {})
|
|
}
|
|
return value
|
|
}
|
|
|
|
/**
|
|
* Cut lines `offset` through `offset + limit - 1` from decoded chunks, stopping
|
|
* at the first character past the page so the rest of the file is never read.
|
|
* Lines before the page are counted, not kept, and the page is refused the
|
|
* moment its bytes exceed `maxBytes`, so one giant line cannot grow memory past
|
|
* the cap either.
|
|
*/
|
|
async function cutPage(
|
|
chunks: AsyncIterable<string>,
|
|
offset: number,
|
|
limit: number,
|
|
maxBytes: number,
|
|
path: string,
|
|
): Promise<Page> {
|
|
const last = offset + limit - 1
|
|
const lines: string[] = []
|
|
let current = ''
|
|
let bytes = 0
|
|
let lineNumber = 1
|
|
const admit = (size: number): void => {
|
|
bytes += size
|
|
if (bytes > maxBytes) {
|
|
throw new RemoteError(
|
|
'workspace-file/too-large',
|
|
`lines ${offset}-${last} of "${path}" exceed the ${maxBytes} byte cap`,
|
|
{ path, limit: maxBytes },
|
|
)
|
|
}
|
|
}
|
|
const complete = (): void => {
|
|
if (lines.length > 0) admit(1)
|
|
lines.push(current)
|
|
current = ''
|
|
}
|
|
for await (const chunk of chunks) {
|
|
let position = 0
|
|
while (position < chunk.length) {
|
|
if (lineNumber > last) return { text: lines.join('\n'), lines: lines.length, eof: false }
|
|
const newline = chunk.indexOf('\n', position)
|
|
const segment = newline === -1 ? chunk.slice(position) : chunk.slice(position, newline)
|
|
if (lineNumber >= offset) {
|
|
admit(Buffer.byteLength(segment, 'utf8'))
|
|
current += segment
|
|
}
|
|
if (newline === -1) break
|
|
if (lineNumber >= offset) complete()
|
|
lineNumber += 1
|
|
position = newline + 1
|
|
}
|
|
}
|
|
// Only an in-page line can be pending here: earlier lines were never kept,
|
|
// and a character past the page returned above.
|
|
if (current.length > 0) complete()
|
|
return { text: lines.join('\n'), lines: lines.length, eof: true }
|
|
}
|
|
|
|
/**
|
|
* Workspace path of `target` relative to `root`, derived from the two canonical
|
|
* `file:` URIs so the answer is `/`-joined on every platform. Empty for the root.
|
|
*/
|
|
function workspacePathOf(rootUrl: string, targetUrl: string): string {
|
|
const root = new URL(rootUrl).pathname.replace(/\/+$/, '')
|
|
const target = new URL(targetUrl).pathname
|
|
if (target === root) return ''
|
|
return target.slice(root.length + 1).split('/').map(decodeURIComponent).join('/')
|
|
}
|
|
|
|
/** Strip the resolved child target: the wire carries names and metadata only. */
|
|
function directoryEntry(child: FsDirEntry): WorkspaceDirectoryEntry {
|
|
return {
|
|
name: child.name,
|
|
type: child.type,
|
|
...child.size === undefined ? {} : { size: child.size },
|
|
}
|
|
}
|
|
|
|
/** Host Remote file reads and workspace directory observations over the composed filesystem. */
|
|
export class WorkspaceFiles extends TypertRemoteService {
|
|
static inject = ['fs', 'sandboxPolicy', 'sessions', 'typert']
|
|
|
|
static Config: z<Config> = z.object({
|
|
maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024),
|
|
maxFileBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER - 1).default(32 * 1024 * 1024),
|
|
maxLines: z.number().step(1).min(1).default(5000),
|
|
maxEntries: z.number().step(1).min(1).default(2000),
|
|
})
|
|
|
|
private readonly feed: WorkspaceChangeFeed
|
|
|
|
/**
|
|
* @param ctx - Host context carrying the filesystem and the sandbox policy.
|
|
* @param config - deployment caps on one page or one listing.
|
|
*/
|
|
constructor(ctx: Context, private readonly config: Config) {
|
|
super(ctx, 'workspaceFiles')
|
|
this.feed = new WorkspaceChangeFeed(ctx)
|
|
ctx.inject(['sessions', 'typert'], (scope) => {
|
|
scope.typert.lookups.register('workspaceFileScope', {
|
|
parameter: 'workspaceFileScope',
|
|
wire: 'workspaceFileScopeId',
|
|
resource: 'session',
|
|
hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope',
|
|
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
|
resolve: async (sessionId) => {
|
|
const live = scope.sessions.get(sessionId)?.header
|
|
const stored = live === undefined
|
|
? await scope.get('sessionPersistence')?.stat(sessionId)
|
|
: undefined
|
|
const header = live ?? stored?.header
|
|
if (header === undefined) return undefined
|
|
return {
|
|
sessionId,
|
|
workspaceRoot: header.cwd ?? scope.sandboxPolicy.workspaceRoot,
|
|
}
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Read one page of lines from a UTF-8 file readable by the filesystem backend.
|
|
* @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
* @param path - absolute path or path relative to the workspace root; files outside it are allowed.
|
|
* @param range - the line window; omitted fields take the page defaults.
|
|
* @param signal - caller cancellation.
|
|
* @returns the page, the file's version at the stat before it, and whether it reaches the last line.
|
|
*/
|
|
@Remote
|
|
async read(
|
|
workspaceFileScope: WorkspaceFileScope,
|
|
path: string,
|
|
range: WorkspaceFileRange,
|
|
signal: AbortSignal,
|
|
): Promise<WorkspaceFileText> {
|
|
const { offset, limit } = this.resolvePage(range)
|
|
const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
|
|
const page = await this.cutPage(target, offset, limit, signal, path)
|
|
if (page.text.includes(NUL)) {
|
|
throw new RemoteError('workspace-file/not-text', `"${path}" contains NUL bytes`, { path })
|
|
}
|
|
return { ...this.statOf(target, info), offset, text: page.text, lines: page.lines, eof: page.eof }
|
|
}
|
|
|
|
/**
|
|
* Read a complete regular file or one byte range without text decoding.
|
|
* @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
* @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided.
|
|
* @param options - optional base file and range; without a range the complete-file cap applies.
|
|
* @param signal - caller cancellation.
|
|
* @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker.
|
|
*/
|
|
@Remote
|
|
async readBytes(
|
|
workspaceFileScope: WorkspaceFileScope,
|
|
path: string,
|
|
options: WorkspaceByteReadOptions,
|
|
signal: AbortSignal,
|
|
): Promise<WorkspaceFileBytes> {
|
|
const window = options.range === undefined ? undefined : this.resolveWindow(options.range, path)
|
|
const resolved = options.baseFile === undefined ? path : await this.relativePath(workspaceFileScope, options.baseFile, path, signal)
|
|
const { target, info } = await this.locateFile(workspaceFileScope, resolved, signal)
|
|
if (window !== undefined) {
|
|
const { offset, length } = window
|
|
const data = await this.ctx.fs.readByteRange(target, window, signal)
|
|
const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size
|
|
return { ...this.statOf(target, info), offset, data, eof }
|
|
}
|
|
const limit = this.config.maxFileBytes
|
|
const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause: unknown) => {
|
|
if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === 'FS_TOO_LARGE') {
|
|
throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }, { cause })
|
|
}
|
|
throw cause
|
|
})
|
|
return { ...this.statOf(target, info), offset: 0, data, eof: true }
|
|
}
|
|
|
|
/**
|
|
* Report one regular file's identity, version, and size without its content.
|
|
* @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
* @param path - absolute path or path relative to the workspace root; files outside it are allowed.
|
|
* @param signal - caller cancellation.
|
|
* @returns the file's absolute path, current version, and byte size.
|
|
*/
|
|
@Remote
|
|
async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat> {
|
|
const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
|
|
return this.statOf(target, info)
|
|
}
|
|
|
|
/**
|
|
* List the direct children of one directory inside the Session's workspace.
|
|
* @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
* @param path - workspace path, absolute or relative to the workspace root.
|
|
* @param signal - caller cancellation.
|
|
* @returns the directory's children in the backend's stable name order, bounded by the entry cap.
|
|
*/
|
|
@Remote
|
|
async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing> {
|
|
const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal)
|
|
// A final link — a Windows junction or a symlink — is listed through the
|
|
// directory it resolves to, matching the child type `listDir` reports for
|
|
// that entry; `read` keeps its own no-follow gate on the final component.
|
|
if (entry.type !== 'directory' && entry.type !== 'symlink') {
|
|
throw new RemoteError(
|
|
'workspace-file/not-directory',
|
|
`"${path}" is a ${entry.type}`,
|
|
{ path, kind: entry.type },
|
|
)
|
|
}
|
|
const target = await this.confine(root, workspaceRoot, path, signal)
|
|
if (entry.type === 'symlink') {
|
|
const info = await this.ctx.fs.stat(target, signal)
|
|
if (info?.type !== 'directory') {
|
|
throw new RemoteError('workspace-file/not-directory', `"${path}" does not resolve to a directory`, { path, kind: 'symlink' })
|
|
}
|
|
}
|
|
const children = await this.ctx.fs.listDir(target, signal)
|
|
return {
|
|
path: workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)),
|
|
entries: children.slice(0, this.config.maxEntries).map(directoryEntry),
|
|
truncated: children.length > this.config.maxEntries,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Watch one file or a directory's direct entries in the Session's filesystem.
|
|
* Files use the backend's read authority; directories remain workspace-scoped.
|
|
* @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
|
* @param path - target path; the Host determines its type and confines directories to the workspace.
|
|
* @param signal - generation cancellation.
|
|
* @returns `ready` once the target watch is active, then current metadata for queued and live invalidations.
|
|
* @throws RemoteError when watching is unavailable or a directory is outside the workspace.
|
|
*/
|
|
@Remote({ mode: 'stream' })
|
|
changes(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
|
|
return this.feed.follow(workspaceFileScope.workspaceRoot, path, signal)
|
|
}
|
|
|
|
private async relativePath(scope: WorkspaceFileScope, baseFile: string, path: string, signal: AbortSignal): Promise<string> {
|
|
const relative = path.replace(/\\/g, '/')
|
|
if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) {
|
|
throw new RemoteError('gateway/bad-request', 'path must be relative when baseFile is provided', {})
|
|
}
|
|
const { target } = await this.locateFile(scope, baseFile, signal)
|
|
const absolute = this.ctx.fs.processPath(target)
|
|
const paths = absolute.startsWith('/') ? posix : win32
|
|
return paths.resolve(paths.dirname(absolute), relative)
|
|
}
|
|
|
|
/** Apply the page defaults and caps here, so the request never carries them implicitly. */
|
|
private resolvePage(range: WorkspaceFileRange): { offset: number; limit: number } {
|
|
const offset = range.offset === undefined ? 1 : integerAtLeast(range.offset, 1, 'offset')
|
|
const limit = range.limit === undefined ? this.config.maxLines : integerAtLeast(range.limit, 1, 'limit')
|
|
if (limit > this.config.maxLines) {
|
|
throw new RemoteError('gateway/bad-request', `limit must be at most ${this.config.maxLines}`, {})
|
|
}
|
|
return { offset, limit }
|
|
}
|
|
|
|
/** Apply the byte-window defaults and cap; a window above the cap is refused, not shortened. */
|
|
private resolveWindow(range: WorkspaceByteRange, path: string): { offset: number; length: number } {
|
|
const offset = range.offset === undefined ? 0 : integerAtLeast(range.offset, 0, 'offset')
|
|
const length = range.length === undefined ? this.config.maxBytes : integerAtLeast(range.length, 1, 'length')
|
|
if (offset + length > Number.MAX_SAFE_INTEGER) {
|
|
throw new RemoteError('gateway/bad-request', 'offset plus length must stay a safe integer', {})
|
|
}
|
|
if (length > this.config.maxBytes) {
|
|
throw new RemoteError(
|
|
'workspace-file/too-large',
|
|
`${length} bytes of "${path}" exceed the ${this.config.maxBytes} byte cap`,
|
|
{ path, limit: this.config.maxBytes },
|
|
)
|
|
}
|
|
return { offset, length }
|
|
}
|
|
/**
|
|
* Inspect the requested path itself before resolution follows its final
|
|
* component. Directory containment is checked separately by `list`.
|
|
*/
|
|
private async inspect(
|
|
workspaceFileScope: WorkspaceFileScope,
|
|
path: string,
|
|
signal: AbortSignal,
|
|
): Promise<{ root: FsTarget; workspaceRoot: string; entry: FsPathInfo }> {
|
|
if (path.length === 0) throw new RemoteError('gateway/bad-request', 'path is required', {})
|
|
const { workspaceRoot } = workspaceFileScope
|
|
const root = await this.ctx.fs.resolve(workspaceRoot, { signal })
|
|
// Gate on the path itself before anything follows it.
|
|
const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal)
|
|
if (entry === undefined) {
|
|
throw new RemoteError('workspace-file/not-found', `no entry at "${path}"`, { path })
|
|
}
|
|
return { root, workspaceRoot, entry }
|
|
}
|
|
|
|
/** Resolve an inspected path and refuse it unless the workspace contains it. */
|
|
private async confine(root: FsTarget, workspaceRoot: string, path: string, signal: AbortSignal): Promise<FsTarget> {
|
|
const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal })
|
|
if (!this.ctx.fs.contains(root, target)) {
|
|
throw new RemoteError('workspace-file/outside-workspace', `"${path}" is outside the workspace`, { path })
|
|
}
|
|
return target
|
|
}
|
|
|
|
/**
|
|
* All gates for a regular file, ending in the one stat that names its version
|
|
* and size. The stat re-checks what `lstat` saw: the file may have gone or
|
|
* changed kind in between.
|
|
*/
|
|
private async locateFile(
|
|
workspaceFileScope: WorkspaceFileScope,
|
|
path: string,
|
|
signal: AbortSignal,
|
|
): Promise<{ target: FsTarget; info: FsInfo }> {
|
|
const { workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal)
|
|
if (entry.type !== 'file') {
|
|
throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${entry.type}`, { path, kind: entry.type })
|
|
}
|
|
const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal })
|
|
const info = await this.ctx.fs.stat(target, signal)
|
|
if (info === undefined) {
|
|
throw new RemoteError('workspace-file/not-found', `no entry at "${path}"`, { path })
|
|
}
|
|
if (info.type !== 'file') {
|
|
throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${info.type}`, { path, kind: info.type })
|
|
}
|
|
return { target, info }
|
|
}
|
|
|
|
private statOf(target: FsTarget, info: FsInfo): WorkspaceFileStat {
|
|
return {
|
|
absolutePath: this.ctx.fs.processPath(target),
|
|
version: info.version,
|
|
...info.size === undefined ? {} : { bytes: info.size },
|
|
}
|
|
}
|
|
|
|
/** Stream the file as text and cut the page, classifying the backend's non-text refusal. */
|
|
private async cutPage(target: FsTarget, offset: number, limit: number, signal: AbortSignal, path: string): Promise<Page> {
|
|
try {
|
|
return await cutPage(await this.ctx.fs.streamText(target, signal), offset, limit, this.config.maxBytes, path)
|
|
} catch (error: unknown) {
|
|
if (isNotTextRefusal(error)) {
|
|
throw new RemoteError('workspace-file/not-text', `"${path}" is not UTF-8 text`, { path }, { cause: error })
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The backend's non-text refusal, recognized by its code alone: the error class
|
|
* belongs to whichever `dsh-fs` instance the provider loaded, so no class
|
|
* identity is shared across the package boundary.
|
|
*/
|
|
function isNotTextRefusal(error: unknown): boolean {
|
|
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'FS_NOT_TEXT'
|
|
}
|
|
|
|
export default WorkspaceFiles
|