runner-launch.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. /** Parent-side invocation and bootstrap state for the private native runner. */
  2. import type { StdioOptions } from 'node:child_process'
  3. import { accessSync, constants as fsConstants, lstatSync, statSync } from 'node:fs'
  4. import { extname, isAbsolute } from 'node:path'
  5. import { inspect } from 'node:util'
  6. import { fileURLToPath } from 'node:url'
  7. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  8. import { childEnv } from './spawn.ts'
  9. import { controlEnvironment } from './control-spawn.ts'
  10. import { SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control'
  11. /** The one private environment variable consumed before target state is restored. */
  12. export const SUBPROCESS_RUNNER_ENV = 'DSH_SUBPROCESS_RUNNER' as const
  13. /** Sentinel used by the packaged bootstrap for the Windows IPC runner. */
  14. export const WINDOWS_RUNNER_SELECTION = 'windows' as const
  15. /** Non-empty command tuple used to launch the private runner entry. */
  16. export type RunnerInvocation = [string, ...string[]]
  17. const SOURCE_TSCONFIG_PATH = fileURLToPath(new URL('../../../../tsconfig.base.json', import.meta.url))
  18. const RUNNER_CONTROL_ENV_PREFIXES = ['NODE_', 'TSX_'] as const
  19. /**
  20. * Resolve the source, built, or packaged entry that calls the same runner core.
  21. * @returns executable and arguments for the active runtime form.
  22. */
  23. export function spawnRunnerInvocation(): RunnerInvocation {
  24. if ('pkg' in process) return [process.execPath]
  25. /* v8 ignore next -- built-artifact smoke imports the emitted JavaScript runner entry;
  26. * source-unit coverage cannot change import.meta.url. */
  27. if (extname(fileURLToPath(import.meta.url)) !== '.ts') {
  28. return [process.execPath, fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/runner'))]
  29. }
  30. return [
  31. process.execPath,
  32. '--import',
  33. import.meta.resolve('tsx/esm'),
  34. fileURLToPath(new URL('./bin.ts', import.meta.url)),
  35. ]
  36. }
  37. /**
  38. * Check the concrete runner executable and entry paths without executing a probe mode.
  39. * @param invocation - resolved executable and runner-entry arguments.
  40. * @returns whether every concrete executable or entry path is accessible.
  41. */
  42. export function runnerInvocationAvailable(invocation: RunnerInvocation = spawnRunnerInvocation()): boolean {
  43. try {
  44. if (isAbsolute(invocation[0])) accessSync(invocation[0], fsConstants.X_OK)
  45. const entry = invocation.at(-1)
  46. if (entry !== undefined && entry !== invocation[0] && isAbsolute(entry)) {
  47. accessSync(entry, fsConstants.R_OK)
  48. }
  49. return true
  50. } catch {
  51. return false
  52. }
  53. }
  54. /**
  55. * Build the bootstrap-safe environment; target overrides arrive through request/IPC.
  56. * @param selection - private runner selector or Linux launch-request locator.
  57. * @param invocation - resolved runner invocation whose source form needs the workspace paths map.
  58. * @returns environment for the runner before target state is restored.
  59. */
  60. export function runnerEnvironment(
  61. selection: string,
  62. invocation?: RunnerInvocation,
  63. ): NodeJS.ProcessEnv {
  64. const entry = invocation?.at(-1)
  65. const env = childEnv()
  66. for (const name of Object.keys(env)) {
  67. const normalized = name.toUpperCase()
  68. if (RUNNER_CONTROL_ENV_PREFIXES.some(prefix => normalized.startsWith(prefix))) {
  69. Reflect.deleteProperty(env, name)
  70. }
  71. }
  72. return {
  73. ...env,
  74. [SUBPROCESS_RUNNER_ENV]: selection,
  75. SYSTEMD_LOG_TARGET: 'null',
  76. ...entry?.endsWith('.ts') === true ? { TSX_TSCONFIG_PATH: SOURCE_TSCONFIG_PATH } : {},
  77. }
  78. }
  79. /**
  80. * Read and delete the private selector before importing or restoring target state.
  81. * @param env - mutable environment containing the private selector.
  82. * @returns the consumed selector, or undefined when no runner was requested.
  83. */
  84. export function consumeRunnerSelection(env: NodeJS.ProcessEnv = process.env): string | undefined {
  85. const selection = env[SUBPROCESS_RUNNER_ENV]
  86. Reflect.deleteProperty(env, SUBPROCESS_RUNNER_ENV)
  87. return selection
  88. }
  89. /**
  90. * Require the private argv delimiter and at least one target argv entry.
  91. * @param argv - private runner arguments.
  92. * @returns copied target argv after the private delimiter.
  93. */
  94. export function parseRunnerTargetArgv(argv: readonly string[]): string[] {
  95. if (argv[0] !== '--' || argv.length < 2) {
  96. throw new Error('subprocess runner requires target argv after a private -- delimiter')
  97. }
  98. return [...argv.slice(1)]
  99. }
  100. /**
  101. * Build direct Linux target stdio, or isolated Windows runner stdio with IPC
  102. * on fd 3 and target carriers on fd 4 through fd 6; optional control uses fd 7.
  103. * @param spec - ordinary subprocess request whose stdio modes are preserved.
  104. * @param ipc - whether to isolate the runner and add its private Node IPC descriptor.
  105. * @param stdinCarrier - runner fd 4 carrier; Windows ignore passes an opened null-device fd.
  106. * @returns child-process stdio options for the runner.
  107. */
  108. export function runnerStdio(
  109. spec: SubprocessSpawnSpec,
  110. ipc: boolean,
  111. stdinCarrier: 'pipe' | number = 'pipe',
  112. ): StdioOptions {
  113. const targetStdio: StdioOptions = [
  114. spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe',
  115. spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe',
  116. spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe',
  117. ]
  118. if (!ipc) {
  119. if (spec.stdio.control === 'pipe') {
  120. while (targetStdio.length < SUBPROCESS_CONTROL_FD) targetStdio.push('ignore')
  121. targetStdio.push('overlapped')
  122. }
  123. return targetStdio
  124. }
  125. const runner: StdioOptions = [
  126. 'ignore',
  127. 'ignore',
  128. 'ignore',
  129. 'ipc',
  130. stdinCarrier,
  131. spec.stdio.stdout === 'inherit' ? 1 : 'pipe',
  132. spec.stdio.stderr === 'inherit' ? 2 : 'pipe',
  133. ]
  134. if (spec.stdio.control === 'pipe') runner.push('overlapped')
  135. return runner
  136. }
  137. function windowsEnvironmentValue(
  138. env: Readonly<Record<string, string | undefined>>,
  139. name: 'PATH' | 'NODEFAULTCURRENTDIRECTORYINEXEPATH',
  140. ): string | undefined {
  141. for (const key of Object.keys(env).sort()) {
  142. if (key.toUpperCase() === name) return env[key]
  143. }
  144. return undefined
  145. }
  146. function executableCandidateExists(candidate: string): boolean {
  147. try {
  148. return !statSync(candidate).isDirectory()
  149. } catch {
  150. try {
  151. const entry = lstatSync(candidate)
  152. return entry.isFile() || entry.isSymbolicLink()
  153. } catch {
  154. return false
  155. }
  156. }
  157. }
  158. function windowsPathDirectories(path: string): string[] {
  159. const directories: string[] = []
  160. let start = 0
  161. while (start < path.length) {
  162. if (path.charAt(start) === ';') {
  163. start += 1
  164. continue
  165. }
  166. const quote = path.charAt(start)
  167. const quoted = quote === '"' || quote === "'"
  168. const quoteEnd = quoted
  169. ? path.indexOf(quote, start + 1)
  170. : -1
  171. const separator = path.indexOf(';', quoted ? quoteEnd < 0 ? path.length : quoteEnd : start)
  172. const end = separator < 0 ? path.length : separator
  173. let directory = path.slice(start, end)
  174. if (directory.startsWith('"') || directory.startsWith("'")) directory = directory.slice(1)
  175. if (directory.endsWith('"') || directory.endsWith("'")) directory = directory.slice(0, -1)
  176. if (directory.length > 0) directories.push(directory)
  177. start = end + 1
  178. }
  179. return directories
  180. }
  181. function windowsFileNameStart(command: string): number {
  182. let start = command.length
  183. while (start > 0 && !/[\\/:]/u.test(command.charAt(start - 1))) start -= 1
  184. return start
  185. }
  186. function windowsSearchPathJoin(directory: string, name: string, cwd: string): string {
  187. let prefix = cwd
  188. let adjustedDirectory = directory
  189. const slash = (value: string): boolean => value === '\\' || value === '/'
  190. if (directory.length > 2 && slash(directory.charAt(0)) && slash(directory.charAt(1))) {
  191. prefix = ''
  192. } else if (directory.length >= 1 && slash(directory.charAt(0))) {
  193. prefix = cwd.slice(0, 2)
  194. } else if (
  195. directory.length >= 2
  196. && directory.charAt(1) === ':'
  197. && (directory.length < 3 || !slash(directory.charAt(2)))
  198. ) {
  199. if (cwd.length < 2 || cwd.slice(0, 2).toLowerCase() !== directory.slice(0, 2).toLowerCase()) {
  200. prefix = ''
  201. } else {
  202. adjustedDirectory = directory.slice(2)
  203. }
  204. } else if (directory.length > 2 && directory.charAt(1) === ':') {
  205. prefix = ''
  206. }
  207. const append = (base: string, part: string): string => {
  208. if (base.length === 0 || part.length === 0) return base + part
  209. return /[\\/:]$/u.test(base) ? base + part : `${base}\\${part}`
  210. }
  211. return append(append(prefix, adjustedDirectory), name)
  212. }
  213. function windowsExecutableNames(command: string, name: string): string[] {
  214. const dot = name.indexOf('.')
  215. const hasExtension = dot >= 0 && dot < name.length - 1
  216. const separator = name.endsWith('.') ? '' : '.'
  217. return [
  218. ...hasExtension ? [command] : [],
  219. `${command}${separator}com`,
  220. `${command}${separator}exe`,
  221. ]
  222. }
  223. /**
  224. * Resolve the executable path with libuv/Node Windows spawn search order while
  225. * preserving the caller's original command-line argv entry separately.
  226. * @param command - original target argv[0].
  227. * @param cwd - final target working directory used for relative search roots.
  228. * @param env - final target environment containing the child PATH.
  229. * @param exists - injectable non-directory candidate probe used by tests.
  230. * @param currentEnv - runner environment supplying PATH fallback and cwd-search policy.
  231. * @returns a resolved application name suitable for `CreateProcessW`, or undefined when no candidate exists.
  232. */
  233. export function resolveWindowsExecutable(
  234. command: string,
  235. cwd: string,
  236. env: Readonly<Record<string, string>>,
  237. exists: (candidate: string) => boolean = executableCandidateExists,
  238. currentEnv: Readonly<Record<string, string | undefined>> = process.env,
  239. ): string | undefined {
  240. const nameStart = windowsFileNameStart(command)
  241. const directory = command.slice(0, nameStart)
  242. const name = command.slice(nameStart)
  243. const hasPath = nameStart !== 0
  244. const roots: string[] = []
  245. if (hasPath) {
  246. roots.push(directory)
  247. } else {
  248. if (windowsEnvironmentValue(currentEnv, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined) {
  249. roots.push('')
  250. }
  251. const path = windowsEnvironmentValue(env, 'PATH') ?? windowsEnvironmentValue(currentEnv, 'PATH') ?? ''
  252. roots.push(...windowsPathDirectories(path))
  253. }
  254. for (const root of roots) {
  255. const base = windowsSearchPathJoin(root, name, cwd)
  256. for (const candidate of windowsExecutableNames(base, name)) {
  257. if (exists(candidate)) return candidate
  258. }
  259. }
  260. return undefined
  261. }
  262. function throwNullByteError(property: string, value: string, argument: boolean): never {
  263. const subject = argument ? `The argument '${property}'` : `The property '${property}'`
  264. const error = new TypeError(`${subject} must be a string without null bytes. Received ${inspect(value)}`)
  265. Object.assign(error, { code: 'ERR_INVALID_ARG_VALUE' })
  266. throw error
  267. }
  268. function validateNoNullByte(property: string, value: string, argument = false): void {
  269. if (value.includes('\0')) throwNullByteError(property, value, argument)
  270. }
  271. /**
  272. * Materialize and synchronously validate the final target environment.
  273. * @param spec - final target argv, cwd, and environment overrides.
  274. * @returns complete target environment after Node-equivalent validation.
  275. */
  276. export function targetEnvironment(
  277. spec: Pick<SubprocessSpawnSpec, 'argv' | 'cwd' | 'env'> & { stdio?: SubprocessSpawnSpec['stdio'] },
  278. ): Record<string, string> {
  279. spec.argv.forEach((value, index) => {
  280. validateNoNullByte(index === 0 ? 'file' : `args[${String(index - 1)}]`, value, true)
  281. })
  282. validateNoNullByte('options.cwd', spec.cwd)
  283. const env = Object.fromEntries(
  284. Object.entries(childEnv(spec.env)).filter((entry): entry is [string, string] => entry[1] !== undefined),
  285. )
  286. for (const [key, value] of Object.entries(env)) {
  287. validateNoNullByte(`options.env['${key}']`, key)
  288. validateNoNullByte(`options.env['${key}']`, value)
  289. }
  290. return controlEnvironment(env, spec.stdio?.control)
  291. }