| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- /** Parent-side invocation and bootstrap state for the private native runner. */
- import type { StdioOptions } from 'node:child_process'
- import { accessSync, constants as fsConstants, lstatSync, statSync } from 'node:fs'
- import { extname, isAbsolute } from 'node:path'
- import { inspect } from 'node:util'
- import { fileURLToPath } from 'node:url'
- import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
- import { childEnv } from './spawn.ts'
- import { controlEnvironment } from './control-spawn.ts'
- import { SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control'
- /** The one private environment variable consumed before target state is restored. */
- export const SUBPROCESS_RUNNER_ENV = 'DSH_SUBPROCESS_RUNNER' as const
- /** Sentinel used by the packaged bootstrap for the Windows IPC runner. */
- export const WINDOWS_RUNNER_SELECTION = 'windows' as const
- /** Non-empty command tuple used to launch the private runner entry. */
- export type RunnerInvocation = [string, ...string[]]
- const SOURCE_TSCONFIG_PATH = fileURLToPath(new URL('../../../../tsconfig.base.json', import.meta.url))
- const RUNNER_CONTROL_ENV_PREFIXES = ['NODE_', 'TSX_'] as const
- /**
- * Resolve the source, built, or packaged entry that calls the same runner core.
- * @returns executable and arguments for the active runtime form.
- */
- export function spawnRunnerInvocation(): RunnerInvocation {
- if ('pkg' in process) return [process.execPath]
- /* v8 ignore next -- built-artifact smoke imports the emitted JavaScript runner entry;
- * source-unit coverage cannot change import.meta.url. */
- if (extname(fileURLToPath(import.meta.url)) !== '.ts') {
- return [process.execPath, fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/runner'))]
- }
- return [
- process.execPath,
- '--import',
- import.meta.resolve('tsx/esm'),
- fileURLToPath(new URL('./bin.ts', import.meta.url)),
- ]
- }
- /**
- * Check the concrete runner executable and entry paths without executing a probe mode.
- * @param invocation - resolved executable and runner-entry arguments.
- * @returns whether every concrete executable or entry path is accessible.
- */
- export function runnerInvocationAvailable(invocation: RunnerInvocation = spawnRunnerInvocation()): boolean {
- try {
- if (isAbsolute(invocation[0])) accessSync(invocation[0], fsConstants.X_OK)
- const entry = invocation.at(-1)
- if (entry !== undefined && entry !== invocation[0] && isAbsolute(entry)) {
- accessSync(entry, fsConstants.R_OK)
- }
- return true
- } catch {
- return false
- }
- }
- /**
- * Build the bootstrap-safe environment; target overrides arrive through request/IPC.
- * @param selection - private runner selector or Linux launch-request locator.
- * @param invocation - resolved runner invocation whose source form needs the workspace paths map.
- * @returns environment for the runner before target state is restored.
- */
- export function runnerEnvironment(
- selection: string,
- invocation?: RunnerInvocation,
- ): NodeJS.ProcessEnv {
- const entry = invocation?.at(-1)
- const env = childEnv()
- for (const name of Object.keys(env)) {
- const normalized = name.toUpperCase()
- if (RUNNER_CONTROL_ENV_PREFIXES.some(prefix => normalized.startsWith(prefix))) {
- Reflect.deleteProperty(env, name)
- }
- }
- return {
- ...env,
- [SUBPROCESS_RUNNER_ENV]: selection,
- SYSTEMD_LOG_TARGET: 'null',
- ...entry?.endsWith('.ts') === true ? { TSX_TSCONFIG_PATH: SOURCE_TSCONFIG_PATH } : {},
- }
- }
- /**
- * Read and delete the private selector before importing or restoring target state.
- * @param env - mutable environment containing the private selector.
- * @returns the consumed selector, or undefined when no runner was requested.
- */
- export function consumeRunnerSelection(env: NodeJS.ProcessEnv = process.env): string | undefined {
- const selection = env[SUBPROCESS_RUNNER_ENV]
- Reflect.deleteProperty(env, SUBPROCESS_RUNNER_ENV)
- return selection
- }
- /**
- * Require the private argv delimiter and at least one target argv entry.
- * @param argv - private runner arguments.
- * @returns copied target argv after the private delimiter.
- */
- export function parseRunnerTargetArgv(argv: readonly string[]): string[] {
- if (argv[0] !== '--' || argv.length < 2) {
- throw new Error('subprocess runner requires target argv after a private -- delimiter')
- }
- return [...argv.slice(1)]
- }
- /**
- * Build direct Linux target stdio, or isolated Windows runner stdio with IPC
- * on fd 3 and target carriers on fd 4 through fd 6; optional control uses fd 7.
- * @param spec - ordinary subprocess request whose stdio modes are preserved.
- * @param ipc - whether to isolate the runner and add its private Node IPC descriptor.
- * @param stdinCarrier - runner fd 4 carrier; Windows ignore passes an opened null-device fd.
- * @returns child-process stdio options for the runner.
- */
- export function runnerStdio(
- spec: SubprocessSpawnSpec,
- ipc: boolean,
- stdinCarrier: 'pipe' | number = 'pipe',
- ): StdioOptions {
- const targetStdio: StdioOptions = [
- spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe',
- spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe',
- spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe',
- ]
- if (!ipc) {
- if (spec.stdio.control === 'pipe') {
- while (targetStdio.length < SUBPROCESS_CONTROL_FD) targetStdio.push('ignore')
- targetStdio.push('overlapped')
- }
- return targetStdio
- }
- const runner: StdioOptions = [
- 'ignore',
- 'ignore',
- 'ignore',
- 'ipc',
- stdinCarrier,
- spec.stdio.stdout === 'inherit' ? 1 : 'pipe',
- spec.stdio.stderr === 'inherit' ? 2 : 'pipe',
- ]
- if (spec.stdio.control === 'pipe') runner.push('overlapped')
- return runner
- }
- function windowsEnvironmentValue(
- env: Readonly<Record<string, string | undefined>>,
- name: 'PATH' | 'NODEFAULTCURRENTDIRECTORYINEXEPATH',
- ): string | undefined {
- for (const key of Object.keys(env).sort()) {
- if (key.toUpperCase() === name) return env[key]
- }
- return undefined
- }
- function executableCandidateExists(candidate: string): boolean {
- try {
- return !statSync(candidate).isDirectory()
- } catch {
- try {
- const entry = lstatSync(candidate)
- return entry.isFile() || entry.isSymbolicLink()
- } catch {
- return false
- }
- }
- }
- function windowsPathDirectories(path: string): string[] {
- const directories: string[] = []
- let start = 0
- while (start < path.length) {
- if (path.charAt(start) === ';') {
- start += 1
- continue
- }
- const quote = path.charAt(start)
- const quoted = quote === '"' || quote === "'"
- const quoteEnd = quoted
- ? path.indexOf(quote, start + 1)
- : -1
- const separator = path.indexOf(';', quoted ? quoteEnd < 0 ? path.length : quoteEnd : start)
- const end = separator < 0 ? path.length : separator
- let directory = path.slice(start, end)
- if (directory.startsWith('"') || directory.startsWith("'")) directory = directory.slice(1)
- if (directory.endsWith('"') || directory.endsWith("'")) directory = directory.slice(0, -1)
- if (directory.length > 0) directories.push(directory)
- start = end + 1
- }
- return directories
- }
- function windowsFileNameStart(command: string): number {
- let start = command.length
- while (start > 0 && !/[\\/:]/u.test(command.charAt(start - 1))) start -= 1
- return start
- }
- function windowsSearchPathJoin(directory: string, name: string, cwd: string): string {
- let prefix = cwd
- let adjustedDirectory = directory
- const slash = (value: string): boolean => value === '\\' || value === '/'
- if (directory.length > 2 && slash(directory.charAt(0)) && slash(directory.charAt(1))) {
- prefix = ''
- } else if (directory.length >= 1 && slash(directory.charAt(0))) {
- prefix = cwd.slice(0, 2)
- } else if (
- directory.length >= 2
- && directory.charAt(1) === ':'
- && (directory.length < 3 || !slash(directory.charAt(2)))
- ) {
- if (cwd.length < 2 || cwd.slice(0, 2).toLowerCase() !== directory.slice(0, 2).toLowerCase()) {
- prefix = ''
- } else {
- adjustedDirectory = directory.slice(2)
- }
- } else if (directory.length > 2 && directory.charAt(1) === ':') {
- prefix = ''
- }
- const append = (base: string, part: string): string => {
- if (base.length === 0 || part.length === 0) return base + part
- return /[\\/:]$/u.test(base) ? base + part : `${base}\\${part}`
- }
- return append(append(prefix, adjustedDirectory), name)
- }
- function windowsExecutableNames(command: string, name: string): string[] {
- const dot = name.indexOf('.')
- const hasExtension = dot >= 0 && dot < name.length - 1
- const separator = name.endsWith('.') ? '' : '.'
- return [
- ...hasExtension ? [command] : [],
- `${command}${separator}com`,
- `${command}${separator}exe`,
- ]
- }
- /**
- * Resolve the executable path with libuv/Node Windows spawn search order while
- * preserving the caller's original command-line argv entry separately.
- * @param command - original target argv[0].
- * @param cwd - final target working directory used for relative search roots.
- * @param env - final target environment containing the child PATH.
- * @param exists - injectable non-directory candidate probe used by tests.
- * @param currentEnv - runner environment supplying PATH fallback and cwd-search policy.
- * @returns a resolved application name suitable for `CreateProcessW`, or undefined when no candidate exists.
- */
- export function resolveWindowsExecutable(
- command: string,
- cwd: string,
- env: Readonly<Record<string, string>>,
- exists: (candidate: string) => boolean = executableCandidateExists,
- currentEnv: Readonly<Record<string, string | undefined>> = process.env,
- ): string | undefined {
- const nameStart = windowsFileNameStart(command)
- const directory = command.slice(0, nameStart)
- const name = command.slice(nameStart)
- const hasPath = nameStart !== 0
- const roots: string[] = []
- if (hasPath) {
- roots.push(directory)
- } else {
- if (windowsEnvironmentValue(currentEnv, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined) {
- roots.push('')
- }
- const path = windowsEnvironmentValue(env, 'PATH') ?? windowsEnvironmentValue(currentEnv, 'PATH') ?? ''
- roots.push(...windowsPathDirectories(path))
- }
- for (const root of roots) {
- const base = windowsSearchPathJoin(root, name, cwd)
- for (const candidate of windowsExecutableNames(base, name)) {
- if (exists(candidate)) return candidate
- }
- }
- return undefined
- }
- function throwNullByteError(property: string, value: string, argument: boolean): never {
- const subject = argument ? `The argument '${property}'` : `The property '${property}'`
- const error = new TypeError(`${subject} must be a string without null bytes. Received ${inspect(value)}`)
- Object.assign(error, { code: 'ERR_INVALID_ARG_VALUE' })
- throw error
- }
- function validateNoNullByte(property: string, value: string, argument = false): void {
- if (value.includes('\0')) throwNullByteError(property, value, argument)
- }
- /**
- * Materialize and synchronously validate the final target environment.
- * @param spec - final target argv, cwd, and environment overrides.
- * @returns complete target environment after Node-equivalent validation.
- */
- export function targetEnvironment(
- spec: Pick<SubprocessSpawnSpec, 'argv' | 'cwd' | 'env'> & { stdio?: SubprocessSpawnSpec['stdio'] },
- ): Record<string, string> {
- spec.argv.forEach((value, index) => {
- validateNoNullByte(index === 0 ? 'file' : `args[${String(index - 1)}]`, value, true)
- })
- validateNoNullByte('options.cwd', spec.cwd)
- const env = Object.fromEntries(
- Object.entries(childEnv(spec.env)).filter((entry): entry is [string, string] => entry[1] !== undefined),
- )
- for (const [key, value] of Object.entries(env)) {
- validateNoNullByte(`options.env['${key}']`, key)
- validateNoNullByte(`options.env['${key}']`, value)
- }
- return controlEnvironment(env, spec.stdio?.control)
- }
|