runner-launch.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  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 } 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, SubprocessTerminalSpawnSpec } 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. /**
  16. * Resolve the source, built, or packaged entry that calls the same runner core.
  17. * @returns executable and arguments for the active runtime form.
  18. */
  19. export function spawnRunnerInvocation(): RunnerInvocation {
  20. if ('pkg' in process) return [process.execPath]
  21. /* v8 ignore next -- built-artifact smoke imports the emitted JavaScript runner entry;
  22. * source-unit coverage cannot change import.meta.url. */
  23. if (extname(fileURLToPath(import.meta.url)) !== '.ts') {
  24. return [process.execPath, fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/runner'))]
  25. }
  26. return [
  27. process.execPath,
  28. '--import',
  29. 'tsx/esm',
  30. fileURLToPath(new URL('./bin.ts', import.meta.url)),
  31. ]
  32. }
  33. /**
  34. * Check the concrete runner executable and entry paths without executing a probe mode.
  35. * @param invocation - resolved executable and runner-entry arguments.
  36. * @returns whether every concrete executable or entry path is accessible.
  37. */
  38. export function runnerInvocationAvailable(invocation: RunnerInvocation = spawnRunnerInvocation()): boolean {
  39. try {
  40. if (isAbsolute(invocation[0])) accessSync(invocation[0], fsConstants.X_OK)
  41. const entry = invocation.at(-1)
  42. if (entry !== undefined && entry !== invocation[0] && isAbsolute(entry)) {
  43. accessSync(entry, fsConstants.R_OK)
  44. }
  45. return true
  46. } catch {
  47. return false
  48. }
  49. }
  50. /**
  51. * Build the bootstrap-safe environment; target overrides arrive through request/IPC.
  52. * @param selection - private runner selector or Linux launch-request locator.
  53. * @returns environment for the runner before target state is restored.
  54. */
  55. export function runnerEnvironment(selection: string): NodeJS.ProcessEnv {
  56. return childEnv({
  57. [SUBPROCESS_RUNNER_ENV]: selection,
  58. SYSTEMD_LOG_TARGET: 'null',
  59. })
  60. }
  61. /**
  62. * Read and delete the private selector before importing or restoring target state.
  63. * @param env - mutable environment containing the private selector.
  64. * @returns the consumed selector, or undefined when no runner was requested.
  65. */
  66. export function consumeRunnerSelection(env: NodeJS.ProcessEnv = process.env): string | undefined {
  67. const selection = env[SUBPROCESS_RUNNER_ENV]
  68. Reflect.deleteProperty(env, SUBPROCESS_RUNNER_ENV)
  69. return selection
  70. }
  71. /**
  72. * Require the private argv delimiter and at least one target argv entry.
  73. * @param argv - private runner arguments.
  74. * @returns copied target argv after the private delimiter.
  75. */
  76. export function parseRunnerTargetArgv(argv: readonly string[]): string[] {
  77. if (argv[0] !== '--' || argv.length < 2) {
  78. throw new Error('subprocess runner requires target argv after a private -- delimiter')
  79. }
  80. return [...argv.slice(1)]
  81. }
  82. /**
  83. * Build the stdio inherited unchanged by the target, optionally with Node IPC on fd 3.
  84. * @param spec - ordinary subprocess request whose stdio modes are preserved.
  85. * @param ipc - whether to append the private Node IPC descriptor.
  86. * @returns child-process stdio options for the runner.
  87. */
  88. export function runnerStdio(
  89. spec: SubprocessSpawnSpec,
  90. ipc: boolean,
  91. ): StdioOptions {
  92. const stdio: StdioOptions = [
  93. spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe',
  94. spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe',
  95. spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe',
  96. ]
  97. if (ipc) (stdio as Array<string>).push('ipc')
  98. return stdio
  99. }
  100. function throwNullByteError(property: string, value: string, argument: boolean): never {
  101. const subject = argument ? `The argument '${property}'` : `The property '${property}'`
  102. const error = new TypeError(`${subject} must be a string without null bytes. Received ${inspect(value)}`)
  103. Object.assign(error, { code: 'ERR_INVALID_ARG_VALUE' })
  104. throw error
  105. }
  106. function validateNoNullByte(property: string, value: string, argument = false): void {
  107. if (value.includes('\0')) throwNullByteError(property, value, argument)
  108. }
  109. /**
  110. * Materialize and synchronously validate the final target environment.
  111. * @param spec - final target argv, cwd, and environment overrides.
  112. * @returns complete target environment after Node-equivalent validation.
  113. */
  114. export function targetEnvironment(
  115. spec: Pick<SubprocessSpawnSpec, 'argv' | 'cwd' | 'env'>,
  116. ): Record<string, string> {
  117. spec.argv.forEach((value, index) => {
  118. validateNoNullByte(index === 0 ? 'file' : `args[${String(index - 1)}]`, value, true)
  119. })
  120. validateNoNullByte('options.cwd', spec.cwd)
  121. const env = Object.fromEntries(
  122. Object.entries(childEnv(spec.env)).filter((entry): entry is [string, string] => entry[1] !== undefined),
  123. )
  124. for (const [key, value] of Object.entries(env)) {
  125. validateNoNullByte(`options.env['${key}']`, key)
  126. validateNoNullByte(`options.env['${key}']`, value)
  127. }
  128. return env
  129. }
  130. /**
  131. * Validate Linux PTY target strings before creating its request or terminal.
  132. * @param spec - terminal subprocess request to validate.
  133. * @returns complete validated target environment.
  134. */
  135. export function validateTerminalTarget(spec: SubprocessTerminalSpawnSpec): Record<string, string> {
  136. return targetEnvironment(spec)
  137. }