protocol.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. /** Bounded, versioned requests between an SSH client and its private remote helper. */
  2. import { randomUUID } from 'node:crypto'
  3. import { EventEmitter } from 'node:events'
  4. import type { Readable, Writable } from 'node:stream'
  5. import { z } from 'zod'
  6. import type { Branded } from '@deepseek-ai/dsh-brand'
  7. /** Wire version shared by the installed helper and client package. */
  8. export const SSH_PROTOCOL_VERSION = 1
  9. /** Maximum prepared or running process handles owned by one helper. */
  10. export const SSH_MAX_PROCESS_HANDLES = 128
  11. /** Maximum open text iterators owned by one helper. */
  12. export const SSH_MAX_TEXT_STREAMS = 128
  13. const managementLimits = {
  14. heartbeat: 1,
  15. close: 1,
  16. 'process.terminate': SSH_MAX_PROCESS_HANDLES,
  17. 'fs.streamClose': SSH_MAX_TEXT_STREAMS,
  18. } as const
  19. type RequestClass = 'ordinary' | keyof typeof managementLimits
  20. function requestClass(method: string): RequestClass {
  21. switch (method) {
  22. case 'heartbeat': case 'close': case 'process.terminate': case 'fs.streamClose': return method
  23. // All other private operations share the configured request budget.
  24. default: return 'ordinary'
  25. }
  26. }
  27. const errorSchema = z.object({ name: z.string(), message: z.string(), code: z.string().optional() }).strict()
  28. type SshRpcRequestId = Branded<'SshRpcRequestId'>
  29. const requestIdSchema = z.string().transform((value): SshRpcRequestId => value as SshRpcRequestId)
  30. const frameSchema = z.discriminatedUnion('type', [
  31. z.object({ type: z.literal('request'), id: requestIdSchema, method: z.string(), params: z.unknown() }).strict(),
  32. z.object({ type: z.literal('result'), id: requestIdSchema, value: z.unknown() }).strict(),
  33. z.object({ type: z.literal('error'), id: requestIdSchema, error: errorSchema }).strict(),
  34. z.object({ type: z.literal('cancel'), id: requestIdSchema }).strict(),
  35. ])
  36. type Frame = z.infer<typeof frameSchema>
  37. type RequestHandler = (method: string, params: unknown, signal: AbortSignal) => Promise<unknown>
  38. function operationError(error: unknown): Error {
  39. return error instanceof Error ? error : new Error(String(error))
  40. }
  41. /** A remote error retains its typed filesystem or sandbox code. */
  42. export class RemoteOperationError extends Error {
  43. constructor(message: string, readonly code?: string) {
  44. super(message)
  45. this.name = 'RemoteOperationError'
  46. }
  47. }
  48. /** The peer owns pending calls and rejects ambiguous operations on connection loss; it never replays requests. */
  49. export class SshRpcPeer extends EventEmitter {
  50. private readonly pending = new Map<SshRpcRequestId, {
  51. resolve(value: unknown): void
  52. reject(error: Error): void
  53. requestClass: RequestClass
  54. }>()
  55. private readonly active = new Map<SshRpcRequestId, { controller: AbortController; requestClass: RequestClass }>()
  56. private writeTail = Promise.resolve()
  57. private queuedBytes = 0
  58. private failure: Error | undefined
  59. constructor(
  60. private readonly input: Readable,
  61. private readonly output: Writable,
  62. private readonly maxFrameBytes: number,
  63. private readonly maxPending: number,
  64. private readonly handler?: RequestHandler,
  65. ) {
  66. super()
  67. input.on('error', (error) => { this.close(error) })
  68. output.on('error', (error) => { this.close(error) })
  69. output.on('close', () => { this.close() })
  70. void this.readFrames().catch((error: unknown) => { this.close(operationError(error)) })
  71. }
  72. /**
  73. * Send one request and validate its response before exposing it to the caller.
  74. * @param method - the private helper operation.
  75. * @param params - JSON request fields.
  76. * @param schema - validation for the remote response.
  77. * @param signal - cancellation without rollback of remote effects.
  78. * @returns the validated response or a transport/remote-operation rejection.
  79. */
  80. async request<T>(method: string, params: unknown, schema: z.ZodType<T>, signal?: AbortSignal): Promise<T> {
  81. signal?.throwIfAborted()
  82. if (this.failure !== undefined) throw this.failure
  83. const kind = requestClass(method)
  84. if (this.atCapacity(kind, this.pending.values())) throw new Error('SSH helper pending request limit reached')
  85. const id = randomUUID() as SshRpcRequestId
  86. const result = Promise.withResolvers<unknown>()
  87. void result.promise.catch(() => {})
  88. this.pending.set(id, { ...result, requestClass: kind })
  89. const abort = (): void => {
  90. // Keep the credit until the remote handler replies; cancellation does
  91. // not mean its resource cleanup has finished.
  92. result.reject(new Error('SSH operation cancelled; a completed remote mutation is not rolled back'))
  93. void this.send({ type: 'cancel', id }).catch(() => {})
  94. }
  95. signal?.addEventListener('abort', abort, { once: true })
  96. try {
  97. void this.send({ type: 'request', id, method, params }).catch((error: unknown) => {
  98. this.pending.delete(id)
  99. result.reject(operationError(error))
  100. })
  101. return schema.parse(await result.promise)
  102. } finally {
  103. signal?.removeEventListener('abort', abort)
  104. }
  105. }
  106. /**
  107. * Fail pending operations and abort remote handlers without claiming rollback.
  108. * @param error - the transport failure reported to all pending operations.
  109. */
  110. close(error = new Error('SSH connection lost; remote operation outcome and cleanup are unknown')): void {
  111. if (this.failure !== undefined) return
  112. this.failure = error
  113. for (const pending of this.pending.values()) pending.reject(error)
  114. this.pending.clear()
  115. for (const { controller } of this.active.values()) controller.abort(error)
  116. this.active.clear()
  117. this.input.destroy()
  118. this.output.destroy()
  119. this.emit('closed', error)
  120. }
  121. private async send(frame: Frame): Promise<void> {
  122. if (this.failure !== undefined) return Promise.reject(this.failure)
  123. const body = Buffer.from(JSON.stringify(frame))
  124. if (body.length > this.maxFrameBytes || this.queuedBytes + body.length + 4 > this.maxFrameBytes * 2) {
  125. return Promise.reject(new Error('SSH helper frame or write queue limit exceeded'))
  126. }
  127. const header = Buffer.alloc(4)
  128. header.writeUInt32BE(body.length)
  129. const bytes = Buffer.concat([header, body])
  130. this.queuedBytes += bytes.length
  131. const write = this.writeTail.then(async () => {
  132. if (this.failure !== undefined) throw this.failure
  133. if (!this.output.write(bytes)) await new Promise<void>((resolve, reject) => {
  134. const cleanup = (): void => {
  135. this.output.off('drain', drained)
  136. this.off('closed', closed)
  137. }
  138. const drained = (): void => { cleanup(); resolve() }
  139. const closed = (error: Error): void => { cleanup(); reject(error) }
  140. this.output.once('drain', drained)
  141. this.once('closed', closed)
  142. if (this.failure !== undefined) closed(this.failure)
  143. })
  144. })
  145. this.writeTail = write.catch((error: unknown) => { this.close(operationError(error)) })
  146. return write.finally(() => { this.queuedBytes -= bytes.length })
  147. }
  148. private async readFrames(): Promise<void> {
  149. const header = Buffer.alloc(4)
  150. let headerBytes = 0
  151. let payload: Buffer | undefined
  152. let payloadBytes = 0
  153. for await (const raw of this.input) {
  154. const chunk: Buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as Uint8Array)
  155. let offset = 0
  156. while (offset < chunk.length) {
  157. if (payload === undefined) {
  158. const count = Math.min(4 - headerBytes, chunk.length - offset)
  159. chunk.copy(header, headerBytes, offset, offset + count)
  160. headerBytes += count
  161. offset += count
  162. if (headerBytes < 4) continue
  163. const size = header.readUInt32BE(0)
  164. if (size === 0 || size > this.maxFrameBytes) throw new Error('SSH helper sent an invalid frame length')
  165. payload = Buffer.alloc(size)
  166. payloadBytes = 0
  167. }
  168. const count = Math.min(payload.length - payloadBytes, chunk.length - offset)
  169. chunk.copy(payload, payloadBytes, offset, offset + count)
  170. payloadBytes += count
  171. offset += count
  172. if (payloadBytes === payload.length) {
  173. const frame = frameSchema.parse(JSON.parse(payload.toString('utf8')))
  174. payload = undefined
  175. headerBytes = 0
  176. this.receive(frame)
  177. }
  178. }
  179. }
  180. throw new Error(headerBytes > 0 || payload !== undefined ? 'SSH helper disconnected during a frame; outcome is unknown' : 'SSH helper disconnected; outcome is unknown')
  181. }
  182. private receive(frame: Frame): void {
  183. if (frame.type === 'result' || frame.type === 'error') {
  184. const pending = this.pending.get(frame.id)
  185. if (pending === undefined) return // A cancelled request can still complete remotely.
  186. this.pending.delete(frame.id)
  187. if (frame.type === 'result') pending.resolve(frame.value)
  188. else pending.reject(new RemoteOperationError(frame.error.message, frame.error.code))
  189. return
  190. }
  191. if (frame.type === 'cancel') {
  192. this.active.get(frame.id)?.controller.abort(new Error('SSH caller cancelled the operation'))
  193. return
  194. }
  195. const kind = requestClass(frame.method)
  196. if (this.handler === undefined || this.active.has(frame.id) || this.atCapacity(kind, this.active.values())) {
  197. throw new Error('SSH helper received an unexpected or excessive request')
  198. }
  199. const controller = new AbortController()
  200. this.active.set(frame.id, { controller, requestClass: kind })
  201. void this.handler(frame.method, frame.params, controller.signal).then(
  202. value => this.send({ type: 'result', id: frame.id, value: value ?? null }),
  203. (error: unknown) => {
  204. const detail = operationError(error)
  205. const code = 'code' in detail && typeof detail.code === 'string' ? detail.code : undefined
  206. return this.send({ type: 'error', id: frame.id, error: {
  207. name: detail.name, message: detail.message, ...(code === undefined ? {} : { code }),
  208. } })
  209. },
  210. ).catch((error: unknown) => { this.close(operationError(error)) })
  211. .finally(() => { this.active.delete(frame.id) })
  212. }
  213. private atCapacity(kind: RequestClass, requests: Iterable<{ requestClass: RequestClass }>): boolean {
  214. const limit = kind === 'ordinary' ? this.maxPending : managementLimits[kind]
  215. let count = 0
  216. for (const request of requests) if (request.requestClass === kind) count++
  217. return count >= limit
  218. }
  219. }