runner-protocol.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. /** Closed private transports shared by the native subprocess runner. */
  2. import {
  3. chmodSync,
  4. existsSync,
  5. lstatSync,
  6. mkdtempSync,
  7. readFileSync,
  8. rmdirSync,
  9. unlinkSync,
  10. writeFileSync,
  11. } from 'node:fs'
  12. import { tmpdir } from 'node:os'
  13. import { basename, dirname, isAbsolute, join } from 'node:path'
  14. /** Target state restored by the Linux bootstrap after systemd establishes the scope. */
  15. export interface LinuxLaunchRequest {
  16. cwd: string
  17. env: Record<string, string>
  18. control?: 'pipe'
  19. }
  20. /** Bounded Node-shaped error fields allowed across a private runner boundary. */
  21. export interface SerializedRunnerError {
  22. name: string
  23. message: string
  24. code?: string
  25. syscall?: string
  26. path?: string
  27. }
  28. /** A Linux pre-exec failure published beside its consumed request. */
  29. export type LinuxStartupError =
  30. { type: 'error'; error: SerializedRunnerError }
  31. /** The only parent-to-runner start message on Windows. */
  32. export interface WindowsStartRequest {
  33. type: 'start'
  34. cwd: string
  35. env: Record<string, string>
  36. control?: 'pipe'
  37. }
  38. /** The only parent-to-runner control message on Windows. */
  39. export interface WindowsTerminateRequest {
  40. type: 'terminate'
  41. }
  42. /** Exactly one direct-result branch is sent by a connected Windows runner. */
  43. export type WindowsRunnerResult =
  44. | { type: 'target-exit'; exitCode: number }
  45. | { type: 'error'; error: SerializedRunnerError }
  46. /** Private paths owned by one Linux ordinary or PTY spawn. */
  47. export interface LinuxLaunchFiles {
  48. directory: string
  49. requestPath: string
  50. startupErrorPath: string
  51. }
  52. function isRecord(value: unknown): value is Record<string, unknown> {
  53. return value !== null && typeof value === 'object' && !Array.isArray(value)
  54. }
  55. function hasExactKeys(value: Record<string, unknown>, required: readonly string[], optional: readonly string[] = []): boolean {
  56. const allowed = new Set([...required, ...optional])
  57. return required.every(key => Object.hasOwn(value, key))
  58. && Object.keys(value).every(key => allowed.has(key))
  59. }
  60. function isStringRecord(value: unknown): value is Record<string, string> {
  61. return isRecord(value) && Object.values(value).every(entry => typeof entry === 'string')
  62. }
  63. function isSerializedRunnerError(value: unknown): value is SerializedRunnerError {
  64. if (!isRecord(value) || !hasExactKeys(
  65. value,
  66. ['name', 'message'],
  67. ['code', 'syscall', 'path'],
  68. )) return false
  69. return typeof value.name === 'string'
  70. && typeof value.message === 'string'
  71. && (value.code === undefined || typeof value.code === 'string')
  72. && (value.syscall === undefined || typeof value.syscall === 'string')
  73. && (value.path === undefined || typeof value.path === 'string')
  74. }
  75. function parseErrorResult(value: Record<string, unknown>): LinuxStartupError {
  76. if (!hasExactKeys(value, ['type', 'error']) || !isSerializedRunnerError(value.error)) {
  77. throw new Error('subprocess runner emitted an invalid error result')
  78. }
  79. if (value.type !== 'error') {
  80. throw new Error('subprocess runner emitted an unknown error result')
  81. }
  82. return { type: 'error', error: value.error }
  83. }
  84. /**
  85. * Create a private 0700 directory and one complete 0600 launch request.
  86. * @param request - target cwd and complete environment for the bootstrap.
  87. * @returns private paths owned by this launch.
  88. */
  89. export function createLinuxLaunchFiles(request: LinuxLaunchRequest): LinuxLaunchFiles {
  90. const directory = mkdtempSync(join(tmpdir(), 'dsh-subprocess-launch-'))
  91. const files = {
  92. directory,
  93. requestPath: join(directory, 'launch-request.json'),
  94. startupErrorPath: join(directory, 'startup-error.json'),
  95. }
  96. try {
  97. chmodSync(directory, 0o700)
  98. writeFileSync(files.requestPath, JSON.stringify(request), { flag: 'wx', mode: 0o600 })
  99. return files
  100. } catch (error) {
  101. cleanupLinuxLaunchFiles(files)
  102. throw error
  103. }
  104. }
  105. /**
  106. * Derive the only permitted startup-error path from an absolute request locator.
  107. * @param requestPath - absolute path to the private launch-request file.
  108. * @returns validated sibling paths for this launch.
  109. */
  110. export function linuxLaunchFilesFromLocator(requestPath: string): LinuxLaunchFiles {
  111. if (!isAbsolute(requestPath) || basename(requestPath) !== 'launch-request.json') {
  112. throw new Error('subprocess runner received an invalid Linux launch-request locator')
  113. }
  114. const directory = dirname(requestPath)
  115. return { directory, requestPath, startupErrorPath: join(directory, 'startup-error.json') }
  116. }
  117. /**
  118. * Strictly read and remove a one-shot Linux launch request.
  119. * @param requestPath - private launch-request path to consume.
  120. * @returns validated target cwd and environment.
  121. */
  122. export function consumeLinuxLaunchRequest(requestPath: string): LinuxLaunchRequest {
  123. const text = readFileSync(requestPath, 'utf8')
  124. unlinkSync(requestPath)
  125. const value: unknown = JSON.parse(text)
  126. if (!isRecord(value) || !hasExactKeys(value, ['cwd', 'env'], ['control'])
  127. || typeof value.cwd !== 'string' || !isStringRecord(value.env)
  128. || (value.control !== undefined && value.control !== 'pipe')) {
  129. throw new Error('subprocess runner received an invalid Linux launch request')
  130. }
  131. return { cwd: value.cwd, env: value.env, ...value.control === 'pipe' ? { control: 'pipe' as const } : {} }
  132. }
  133. /**
  134. * Publish one strict 0600 Linux pre-exec error.
  135. * @param files - private paths for this launch.
  136. * @param error - bounded spawn or runner failure to publish.
  137. */
  138. export function writeLinuxStartupError(files: LinuxLaunchFiles, error: LinuxStartupError): void {
  139. writeFileSync(files.startupErrorPath, JSON.stringify(error), { flag: 'wx', mode: 0o600 })
  140. }
  141. /**
  142. * Read the Linux pre-exec error, if the bootstrap published one.
  143. * @param path - expected startup-error path.
  144. * @returns the validated failure, or undefined when none was published.
  145. */
  146. export function readLinuxStartupError(path: string): LinuxStartupError | undefined {
  147. if (!existsSync(path)) return undefined
  148. const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
  149. if (!isRecord(value)) throw new Error('subprocess runner emitted an invalid startup error')
  150. return parseErrorResult(value)
  151. }
  152. /**
  153. * Strictly parse the single Windows start message.
  154. * @param value - untrusted IPC payload.
  155. * @returns validated target start request.
  156. */
  157. export function parseWindowsStartRequest(value: unknown): WindowsStartRequest {
  158. if (!isRecord(value) || !hasExactKeys(value, ['type', 'cwd', 'env'], ['control'])
  159. || value.type !== 'start' || typeof value.cwd !== 'string' || !isStringRecord(value.env)
  160. || (value.control !== undefined && value.control !== 'pipe')) {
  161. throw new Error('subprocess runner received an invalid Windows start request')
  162. }
  163. return { type: 'start', cwd: value.cwd, env: value.env, ...value.control === 'pipe' ? { control: 'pipe' as const } : {} }
  164. }
  165. /**
  166. * Return true only for the exact, payload-free Windows terminate control.
  167. * @param value - untrusted IPC payload.
  168. * @returns whether the payload is the exact terminate request.
  169. */
  170. export function isWindowsTerminateRequest(value: unknown): value is WindowsTerminateRequest {
  171. return isRecord(value) && hasExactKeys(value, ['type']) && value.type === 'terminate'
  172. }
  173. /**
  174. * Strictly parse one of the two Windows direct-result branches.
  175. * @param value - untrusted IPC payload.
  176. * @returns validated direct-result message.
  177. */
  178. export function parseWindowsRunnerResult(value: unknown): WindowsRunnerResult {
  179. if (!isRecord(value) || typeof value.type !== 'string') {
  180. throw new Error('subprocess runner emitted an invalid Windows result')
  181. }
  182. if (value.type === 'error') return parseErrorResult(value)
  183. if (value.type === 'target-exit') {
  184. const validExitCode = typeof value.exitCode === 'number'
  185. && Number.isSafeInteger(value.exitCode)
  186. && value.exitCode >= 0
  187. if (!hasExactKeys(value, ['type', 'exitCode']) || !validExitCode) {
  188. throw new Error('subprocess runner emitted an invalid target-exit result')
  189. }
  190. return {
  191. type: 'target-exit',
  192. exitCode: value.exitCode as number,
  193. }
  194. }
  195. throw new Error(`subprocess runner emitted an unknown Windows result: ${value.type}`)
  196. }
  197. /**
  198. * Convert an unknown failure into the bounded cross-process error record.
  199. * @param error - failure caught at the process boundary.
  200. * @returns bounded serializable error fields.
  201. */
  202. export function serializeRunnerError(error: unknown): SerializedRunnerError {
  203. const source = error instanceof Error ? error : new Error(String(error))
  204. const node = source as NodeJS.ErrnoException & { path?: string }
  205. return {
  206. name: source.name,
  207. message: source.message,
  208. ...typeof node.code === 'string' ? { code: node.code } : {},
  209. ...typeof node.syscall === 'string' ? { syscall: node.syscall } : {},
  210. ...typeof node.path === 'string' ? { path: node.path } : {},
  211. }
  212. }
  213. /**
  214. * Rebuild a Node-shaped Error from a strict runner record.
  215. * @param serialized - validated bounded error fields.
  216. * @returns reconstructed Error with supported Node fields.
  217. */
  218. export function deserializeRunnerError(serialized: SerializedRunnerError): Error {
  219. const error = new Error(serialized.message)
  220. error.name = serialized.name
  221. return Object.assign(error, {
  222. ...serialized.code === undefined ? {} : { code: serialized.code },
  223. ...serialized.syscall === undefined ? {} : { syscall: serialized.syscall },
  224. ...serialized.path === undefined ? {} : { path: serialized.path },
  225. })
  226. }
  227. /**
  228. * Best-effort removal of only the private paths created for this Linux spawn.
  229. * @param files - exact private paths owned by this launch.
  230. */
  231. export function cleanupLinuxLaunchFiles(files: LinuxLaunchFiles): void {
  232. try {
  233. if (lstatSync(files.directory).isSymbolicLink()) {
  234. unlinkSync(files.directory)
  235. return
  236. }
  237. for (const path of [
  238. files.requestPath,
  239. files.startupErrorPath,
  240. ]) {
  241. try { unlinkSync(path) } catch (error) {
  242. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  243. }
  244. }
  245. rmdirSync(files.directory)
  246. } catch {
  247. // Crash residue remains private and no later spawn reuses this directory.
  248. }
  249. }