index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. /**
  2. * Workspace file service: read-only file previews, workspace directory
  3. * listings, and the agent-write change feed, exposed as `workspaceFiles`.
  4. *
  5. * File reads follow the composed filesystem's read access, including paths
  6. * outside the workspace. The Session's policy supplies the base for relative
  7. * paths, not a read-containment restriction. Directory listings and change
  8. * observations remain workspace-scoped. File-kind checks and configured read
  9. * caps apply to every preview; this service exposes no mutations.
  10. *
  11. * A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it
  12. * goes, so the file is read only up to the first character past the page and
  13. * never held whole in memory; the NUL scan runs on the page itself.
  14. *
  15. * This is NOT modelled on `session.openWorkspacePath`. That endpoint hands a
  16. * path to the local opener and leaves the effect on the machine; this one sends
  17. * file content across the wire, which is a different level of exposure.
  18. */
  19. import { posix, win32 } from 'node:path'
  20. import type { Context } from '@deepseek-ai/cordis'
  21. import z from '@deepseek-ai/schemastery'
  22. import type { Agent } from '@deepseek-ai/dsh-agent'
  23. import type {} from '@deepseek-ai/dsh-fs'
  24. import type { FsDirEntry, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
  25. import type {} from '@deepseek-ai/dsh-sandbox-policy'
  26. import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
  27. import { WorkspaceChangeFeed } from './changes.ts'
  28. import type {
  29. WorkspaceByteRange,
  30. WorkspaceDirectoryEntry,
  31. WorkspaceDirectoryListing,
  32. WorkspaceFileBytes,
  33. WorkspaceFileRange,
  34. WorkspaceFileStat,
  35. WorkspaceFileText,
  36. WorkspaceFileWatchFrame,
  37. } from './types.ts'
  38. export type * from './types.ts'
  39. declare module '@deepseek-ai/cordis' {
  40. interface Context {
  41. /** Host owner of the `workspaceFiles` Remote namespace. */
  42. workspaceFiles: WorkspaceFiles
  43. }
  44. }
  45. /** Deployment caps on one page or one listing. */
  46. export interface Config {
  47. /**
  48. * Inclusive byte cap on one page's text and on one byte window.
  49. *
  50. * A page above this fails; it is not shortened, because a silently cut page
  51. * reads as the whole page. A byte window asking for more is refused the same
  52. * way. The file itself has no size cap: a caller pages through it.
  53. */
  54. readonly maxBytes: number
  55. /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */
  56. readonly maxFileBytes: number
  57. /** Default and largest page size in lines; a request asking for more is refused. */
  58. readonly maxLines: number
  59. /** Cap on returned directory entries; the rest is dropped and reported cut. */
  60. readonly maxEntries: number
  61. }
  62. /** One page cut from a decoded text stream. */
  63. interface Page {
  64. readonly text: string
  65. /** Lines in `text`; `0` for a page past the last line. */
  66. readonly lines: number
  67. readonly eof: boolean
  68. }
  69. /** The byte text never carries: its presence marks a page as binary. */
  70. const NUL = String.fromCharCode(0)
  71. /** Refuse anything the wire schema admits as a number but a window cannot use: only safe integers index a file. */
  72. function integerAtLeast(value: number, min: number, name: string): number {
  73. if (!Number.isSafeInteger(value) || value < min) {
  74. throw new RemoteError('gateway/bad-request', `${name} must be a safe integer of at least ${min}`, {})
  75. }
  76. return value
  77. }
  78. /**
  79. * Cut lines `offset` through `offset + limit - 1` from decoded chunks, stopping
  80. * at the first character past the page so the rest of the file is never read.
  81. * Lines before the page are counted, not kept, and the page is refused the
  82. * moment its bytes exceed `maxBytes`, so one giant line cannot grow memory past
  83. * the cap either.
  84. */
  85. async function cutPage(
  86. chunks: AsyncIterable<string>,
  87. offset: number,
  88. limit: number,
  89. maxBytes: number,
  90. path: string,
  91. ): Promise<Page> {
  92. const last = offset + limit - 1
  93. const lines: string[] = []
  94. let current = ''
  95. let bytes = 0
  96. let lineNumber = 1
  97. const admit = (size: number): void => {
  98. bytes += size
  99. if (bytes > maxBytes) {
  100. throw new RemoteError(
  101. 'workspace-file/too-large',
  102. `lines ${offset}-${last} of "${path}" exceed the ${maxBytes} byte cap`,
  103. { path, limit: maxBytes },
  104. )
  105. }
  106. }
  107. const complete = (): void => {
  108. if (lines.length > 0) admit(1)
  109. lines.push(current)
  110. current = ''
  111. }
  112. for await (const chunk of chunks) {
  113. let position = 0
  114. while (position < chunk.length) {
  115. if (lineNumber > last) return { text: lines.join('\n'), lines: lines.length, eof: false }
  116. const newline = chunk.indexOf('\n', position)
  117. const segment = newline === -1 ? chunk.slice(position) : chunk.slice(position, newline)
  118. if (lineNumber >= offset) {
  119. admit(Buffer.byteLength(segment, 'utf8'))
  120. current += segment
  121. }
  122. if (newline === -1) break
  123. if (lineNumber >= offset) complete()
  124. lineNumber += 1
  125. position = newline + 1
  126. }
  127. }
  128. // Only an in-page line can be pending here: earlier lines were never kept,
  129. // and a character past the page returned above.
  130. if (current.length > 0) complete()
  131. return { text: lines.join('\n'), lines: lines.length, eof: true }
  132. }
  133. /**
  134. * Workspace path of `target` relative to `root`, derived from the two canonical
  135. * `file:` URIs so the answer is `/`-joined on every platform. Empty for the root.
  136. */
  137. function workspacePathOf(rootUrl: string, targetUrl: string): string {
  138. const root = new URL(rootUrl).pathname.replace(/\/+$/, '')
  139. const target = new URL(targetUrl).pathname
  140. if (target === root) return ''
  141. return target.slice(root.length + 1).split('/').map(decodeURIComponent).join('/')
  142. }
  143. /** Strip the resolved child target: the wire carries names and metadata only. */
  144. function directoryEntry(child: FsDirEntry): WorkspaceDirectoryEntry {
  145. return {
  146. name: child.name,
  147. type: child.type,
  148. ...child.size === undefined ? {} : { size: child.size },
  149. }
  150. }
  151. /** Host Remote file reads and workspace directory observations over the composed filesystem. */
  152. export class WorkspaceFiles extends TypertRemoteService {
  153. static inject = ['fs', 'sandboxPolicy', 'typert']
  154. static Config: z<Config> = z.object({
  155. maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024),
  156. maxFileBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER - 1).default(32 * 1024 * 1024),
  157. maxLines: z.number().step(1).min(1).default(5000),
  158. maxEntries: z.number().step(1).min(1).default(2000),
  159. })
  160. private readonly feed: WorkspaceChangeFeed
  161. /**
  162. * @param ctx - Host context carrying the filesystem and the sandbox policy.
  163. * @param config - deployment caps on one page or one listing.
  164. */
  165. constructor(ctx: Context, private readonly config: Config) {
  166. super(ctx, 'workspaceFiles')
  167. this.feed = new WorkspaceChangeFeed(ctx)
  168. }
  169. /**
  170. * Read one page of lines from a UTF-8 file readable by the filesystem backend.
  171. * @param agent - target Agent resolved from the Session identity on the wire.
  172. * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  173. * @param range - the line window; omitted fields take the page defaults.
  174. * @param signal - caller cancellation.
  175. * @returns the page, the file's version at the stat before it, and whether it reaches the last line.
  176. */
  177. @Remote
  178. async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText> {
  179. const { offset, limit } = this.resolvePage(range)
  180. const { target, info } = await this.locateFile(agent, path, signal)
  181. const page = await this.cutPage(target, offset, limit, signal, path)
  182. if (page.text.includes(NUL)) {
  183. throw new RemoteError('workspace-file/not-text', `"${path}" contains NUL bytes`, { path })
  184. }
  185. return { ...this.statOf(target, info), offset, text: page.text, lines: page.lines, eof: page.eof }
  186. }
  187. /**
  188. * Read one byte window of a regular file readable by the filesystem backend: raw
  189. * bytes, no text decoding and no binary rejection.
  190. * @param agent - target Agent resolved from the Session identity on the wire.
  191. * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  192. * @param range - the byte window; omitted fields take the window defaults.
  193. * @param signal - caller cancellation.
  194. * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
  195. */
  196. @Remote
  197. async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes> {
  198. const { offset, length } = this.resolveWindow(range, path)
  199. const { target, info } = await this.locateFile(agent, path, signal)
  200. const data = await this.ctx.fs.readByteRange(target, { offset, length }, signal)
  201. const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size
  202. return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof }
  203. }
  204. /**
  205. * Read a complete regular file as bytes, subject to the configured full-file cap.
  206. * @param agent - target Agent whose workspace resolves relative paths.
  207. * @param path - absolute or workspace-relative file path.
  208. * @param signal - caller cancellation.
  209. * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
  210. */
  211. @Remote
  212. async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
  213. const { target, info } = await this.locateFile(agent, path, signal)
  214. const limit = this.config.maxFileBytes
  215. if (info.size !== undefined && info.size > limit) {
  216. throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
  217. }
  218. const data = await this.ctx.fs.readByteRange(target, { offset: 0, length: limit + 1 }, signal)
  219. if (data.length > limit) {
  220. throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
  221. }
  222. return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true }
  223. }
  224. /**
  225. * Read a complete file relative to another file's directory, including outside the workspace.
  226. * @param agent - Agent whose workspace resolves the base file's relative path.
  227. * @param path - base file, absolute or workspace-relative.
  228. * @param relativePath - relative filesystem path, not a URL or absolute path.
  229. * @param signal - caller cancellation.
  230. * @returns the complete related file using the ordinary file-size and access checks.
  231. */
  232. @Remote
  233. async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
  234. const relative = relativePath.replace(/\\/g, '/')
  235. if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) {
  236. throw new RemoteError('gateway/bad-request', 'relativePath must be a relative filesystem path', {})
  237. }
  238. const { target } = await this.locateFile(agent, path, signal)
  239. const absolute = this.ctx.fs.processPath(target)
  240. const paths = absolute.startsWith('/') ? posix : win32
  241. return this.readAll(agent, paths.resolve(paths.dirname(absolute), relative), signal)
  242. }
  243. /**
  244. * Report one regular file's identity, version, and size without its content.
  245. * @param agent - target Agent resolved from the Session identity on the wire.
  246. * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  247. * @param signal - caller cancellation.
  248. * @returns the file's absolute path, current version, and byte size.
  249. */
  250. @Remote
  251. async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat> {
  252. const { target, info } = await this.locateFile(agent, path, signal)
  253. return this.statOf(target, info)
  254. }
  255. /**
  256. * List the direct children of one directory inside the Agent's workspace.
  257. * @param agent - target Agent resolved from the Session identity on the wire.
  258. * @param path - workspace path, absolute or relative to the workspace root.
  259. * @param signal - caller cancellation.
  260. * @returns the directory's children in the backend's stable name order, bounded by the entry cap.
  261. */
  262. @Remote
  263. async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing> {
  264. const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal)
  265. if (entry.type !== 'directory') {
  266. throw new RemoteError(
  267. 'workspace-file/not-directory',
  268. `"${path}" is a ${entry.type}`,
  269. { path, kind: entry.type },
  270. )
  271. }
  272. const target = await this.confine(root, workspaceRoot, path, signal)
  273. const children = await this.ctx.fs.listDir(target, signal)
  274. return {
  275. path: workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)),
  276. entries: children.slice(0, this.config.maxEntries).map(directoryEntry),
  277. truncated: children.length > this.config.maxEntries,
  278. }
  279. }
  280. /**
  281. * Stream every `fs/observed` observation of a file inside the Agent's
  282. * workspace. Only Agent filesystem operations report here; the OS is not
  283. * watched.
  284. * @param agent - target Agent resolved from the Session identity on the wire.
  285. * @param signal - generation cancellation.
  286. * @returns `ready` once the Host observation queue is active and the workspace
  287. * root is resolved, then queued and live observations in emission order.
  288. */
  289. @Remote({ mode: 'stream' })
  290. changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
  291. return this.feed.follow(this.workspaceRootOf(agent), signal)
  292. }
  293. /** Apply the page defaults and caps here, so the request never carries them implicitly. */
  294. private resolvePage(range: WorkspaceFileRange): { offset: number; limit: number } {
  295. const offset = range.offset === undefined ? 1 : integerAtLeast(range.offset, 1, 'offset')
  296. const limit = range.limit === undefined ? this.config.maxLines : integerAtLeast(range.limit, 1, 'limit')
  297. if (limit > this.config.maxLines) {
  298. throw new RemoteError('gateway/bad-request', `limit must be at most ${this.config.maxLines}`, {})
  299. }
  300. return { offset, limit }
  301. }
  302. /** Apply the byte-window defaults and cap; a window above the cap is refused, not shortened. */
  303. private resolveWindow(range: WorkspaceByteRange, path: string): { offset: number; length: number } {
  304. const offset = range.offset === undefined ? 0 : integerAtLeast(range.offset, 0, 'offset')
  305. const length = range.length === undefined ? this.config.maxBytes : integerAtLeast(range.length, 1, 'length')
  306. if (offset + length > Number.MAX_SAFE_INTEGER) {
  307. throw new RemoteError('gateway/bad-request', 'offset plus length must stay a safe integer', {})
  308. }
  309. if (length > this.config.maxBytes) {
  310. throw new RemoteError(
  311. 'workspace-file/too-large',
  312. `${length} bytes of "${path}" exceed the ${this.config.maxBytes} byte cap`,
  313. { path, limit: this.config.maxBytes },
  314. )
  315. }
  316. return { offset, length }
  317. }
  318. /**
  319. * The workspace root comes from the policy, not from the backend's own cwd
  320. * default: the `minimal` preset shadows the host provider with a bare
  321. * `fs-local` whose cwd differs, and resolving explicitly makes the answer
  322. * the same whichever instance answers.
  323. */
  324. private workspaceRootOf(agent: Agent): string {
  325. return this.ctx.sandboxPolicy.resolve({ session: agent.session }).workspaceRoot
  326. }
  327. /**
  328. * Inspect the requested path itself before resolution follows its final
  329. * component. Directory containment is checked separately by `list`.
  330. */
  331. private async inspect(
  332. agent: Agent,
  333. path: string,
  334. signal: AbortSignal,
  335. ): Promise<{ root: FsTarget; workspaceRoot: string; entry: FsPathInfo }> {
  336. if (path.length === 0) throw new RemoteError('gateway/bad-request', 'path is required', {})
  337. const workspaceRoot = this.workspaceRootOf(agent)
  338. const root = await this.ctx.fs.resolve(workspaceRoot, { signal })
  339. // Gate on the path itself before anything follows it.
  340. const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal)
  341. if (entry === undefined) {
  342. throw new RemoteError('workspace-file/not-found', `no entry at "${path}"`, { path })
  343. }
  344. return { root, workspaceRoot, entry }
  345. }
  346. /** Resolve an inspected path and refuse it unless the workspace contains it. */
  347. private async confine(root: FsTarget, workspaceRoot: string, path: string, signal: AbortSignal): Promise<FsTarget> {
  348. const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal })
  349. if (!this.ctx.fs.contains(root, target)) {
  350. throw new RemoteError('workspace-file/outside-workspace', `"${path}" is outside the workspace`, { path })
  351. }
  352. return target
  353. }
  354. /**
  355. * All gates for a regular file, ending in the one stat that names its version
  356. * and size. The stat re-checks what `lstat` saw: the file may have gone or
  357. * changed kind in between.
  358. */
  359. private async locateFile(agent: Agent, path: string, signal: AbortSignal): Promise<{ target: FsTarget; info: FsInfo }> {
  360. const { workspaceRoot, entry } = await this.inspect(agent, path, signal)
  361. if (entry.type !== 'file') {
  362. throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${entry.type}`, { path, kind: entry.type })
  363. }
  364. const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal })
  365. const info = await this.ctx.fs.stat(target, signal)
  366. if (info === undefined) {
  367. throw new RemoteError('workspace-file/not-found', `no entry at "${path}"`, { path })
  368. }
  369. if (info.type !== 'file') {
  370. throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${info.type}`, { path, kind: info.type })
  371. }
  372. return { target, info }
  373. }
  374. private statOf(target: FsTarget, info: FsInfo): WorkspaceFileStat {
  375. return {
  376. absolutePath: this.ctx.fs.processPath(target),
  377. version: info.version,
  378. ...info.size === undefined ? {} : { bytes: info.size },
  379. }
  380. }
  381. /** Stream the file as text and cut the page, classifying the backend's non-text refusal. */
  382. private async cutPage(target: FsTarget, offset: number, limit: number, signal: AbortSignal, path: string): Promise<Page> {
  383. try {
  384. return await cutPage(await this.ctx.fs.streamText(target, signal), offset, limit, this.config.maxBytes, path)
  385. } catch (error: unknown) {
  386. if (isNotTextRefusal(error)) {
  387. throw new RemoteError('workspace-file/not-text', `"${path}" is not UTF-8 text`, { path }, { cause: error })
  388. }
  389. throw error
  390. }
  391. }
  392. }
  393. /**
  394. * The backend's non-text refusal, recognized by its code alone: the error class
  395. * belongs to whichever `dsh-fs` instance the provider loaded, so no class
  396. * identity is shared across the package boundary.
  397. */
  398. function isNotTextRefusal(error: unknown): boolean {
  399. return typeof error === 'object' && error !== null && 'code' in error && error.code === 'FS_NOT_TEXT'
  400. }
  401. export default WorkspaceFiles