Files
deepseek-harness/packages/api/workspace-files/src/index.ts
T

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