runner-launch.ts 11 KB

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