index.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. /** Filesystem provider preserving remote identities and helper-owned atomic mutations. */
  2. import { posix } from 'node:path'
  3. import { pathToFileURL } from 'node:url'
  4. import { FileSystem, FsError } from '@deepseek-ai/dsh-fs'
  5. import type { FsDirEntry, FsEditOutcome, FsEditRequest, FsErrorCode, FsInfo, FsPathInfo, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs'
  6. import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
  7. import type {} from '@deepseek-ai/dsh-sandbox-policy'
  8. import type {} from '@deepseek-ai/dsh-ssh'
  9. import { RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol'
  10. import { editResultSchema, entriesSchema, infoSchema, pathInfoSchema, targetSchema, textStreamIdSchema, writeResultSchema } from '@deepseek-ai/dsh-ssh/schemas'
  11. import { z } from 'zod'
  12. const errorCodes: Record<FsErrorCode, true> = {
  13. FS_NOT_FOUND: true, FS_NOT_DIRECTORY: true, FS_NOT_TEXT: true, FS_NOT_REGULAR_FILE: true,
  14. FS_TOO_LARGE: true, FS_PERMISSION_DENIED: true, FS_SANDBOX_DENIED: true, FS_IO_ERROR: true,
  15. FS_STALE_VERSION: true, FS_NOT_OBSERVED: true, FS_AMBIGUOUS_EDIT: true, FS_EDIT_NOT_FOUND: true, FS_ABORTED: true,
  16. }
  17. /** Remote filesystem paired with the SSH subprocess and sandbox providers. */
  18. export class SshFileSystem extends FileSystem {
  19. static inject = ['ssh', 'sandboxPolicy']
  20. override get sandboxMode(): SandboxMode { return this.ctx.sandboxPolicy.defaultMode }
  21. override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
  22. return await this.call('fs.resolve', { path, cwd: opts?.cwd }, targetSchema, opts?.signal) as FsTarget
  23. }
  24. override processPath(target: FsTarget): string { return String(target.targetKey) }
  25. override fileUrl(target: FsTarget): string {
  26. return pathToFileURL(this.processPath(target)).href
  27. }
  28. override contains(parent: FsTarget, child: FsTarget): boolean {
  29. const path = posix.relative(this.processPath(parent), this.processPath(child))
  30. return path === '' || (!path.startsWith('../') && path !== '..' && !posix.isAbsolute(path))
  31. }
  32. override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
  33. return await this.call('fs.stat', { target }, infoSchema.nullable(), signal) as FsInfo | null ?? undefined
  34. }
  35. override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
  36. return await this.call('fs.lstat', { path, cwd: opts?.cwd }, pathInfoSchema.nullable(), signal) as FsPathInfo | null ?? undefined
  37. }
  38. override readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
  39. return this.call('fs.readText', { target }, z.string(), signal)
  40. }
  41. override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
  42. const id = await this.call('fs.stream', { target }, textStreamIdSchema, signal)
  43. const call = this.call.bind(this)
  44. return (async function* () {
  45. let ended = false
  46. try {
  47. while (!ended) {
  48. signal?.throwIfAborted()
  49. const next = await call('fs.next', { id }, z.object({ done: z.boolean(), value: z.string() }).strict(), signal)
  50. ended = next.done
  51. if (next.value.length > 0) yield next.value
  52. }
  53. } finally {
  54. if (!ended) await call('fs.streamClose', { id }, z.null()).catch(() => {})
  55. }
  56. })()
  57. }
  58. override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
  59. return Buffer.from(await this.call('fs.readBytes', { target, maxBytes }, z.base64(), signal), 'base64')
  60. }
  61. override async readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise<Uint8Array> {
  62. return Buffer.from(await this.call('fs.readRange', { target, ...range }, z.base64(), signal), 'base64')
  63. }
  64. override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
  65. return await this.call('fs.list', { target }, entriesSchema, signal) as FsDirEntry[]
  66. }
  67. override async writeText(
  68. target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy,
  69. ): Promise<FsWriteOutcome> {
  70. const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
  71. return await this.call('fs.write', { target, content, expected, policy }, writeResultSchema, signal) as FsWriteOutcome
  72. }
  73. override async editText(
  74. target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion },
  75. signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy,
  76. ): Promise<FsEditOutcome> {
  77. const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
  78. return await this.call('fs.edit', { target, edit, expected, policy }, editResultSchema, signal) as FsEditOutcome
  79. }
  80. private async call<T>(method: string, params: unknown, schema: z.ZodType<T>, signal?: AbortSignal): Promise<T> {
  81. try { return await this.ctx.ssh.request(method, params, schema, signal) } catch (error) {
  82. if (error instanceof RemoteOperationError && error.code !== undefined && Object.hasOwn(errorCodes, error.code)) {
  83. throw new FsError(error.message, error.code as FsErrorCode, { cause: error })
  84. }
  85. throw new FsError(error instanceof Error ? error.message : String(error), signal?.aborted ? 'FS_ABORTED' : 'FS_IO_ERROR', { cause: error })
  86. }
  87. }
  88. }
  89. export default SshFileSystem