import { constants as fileSystemConstants } from 'node:fs' import { access, realpath, stat } from 'node:fs/promises' import { isAbsolute, relative, resolve, sep, win32 } from 'node:path' import spawn from 'cross-spawn' import { z } from 'zod' import type { WorkspaceAccess } from '../workspace' import { terminateProcessTreeAndWait, type WaitableProcessTreeChild } from './child-process-termination' import { buildCredentialFilteredUserEnvironment } from './process-environment' import { PagedOutputStore, type PagedOutputPage, type PagedOutputReference, type PagedOutputWriter } from './paged-output-store' const DEFAULT_TIMEOUT_MS = 120_000 const MAXIMUM_TIMEOUT_MS = 10 * 60_000 const MAXIMUM_OUTPUT_BYTES = 96 * 1024 const TERMINATION_WAIT_MS = 2_000 export const processExecuteInputSchema = z .object({ command: z.string().trim().min(1).max(100_000), cwd: z .string() .trim() .min(1) .max(4_096) .describe( '工作目录,可使用绝对路径或相对于当前工作区的路径;省略时使用工作区根目录,也可选择工作区外目录。' ) .optional(), timeoutMs: z .number() .int() .min(1_000) .max(MAXIMUM_TIMEOUT_MS) .default(DEFAULT_TIMEOUT_MS) }) .strict() export type ProcessExecuteInput = z.input< typeof processExecuteInputSchema > export type ProcessShellSummary = { kind: 'powershell' | 'bash' | 'sh' label: string } export type ProcessExecuteResult = { shell: ProcessShellSummary cwd: string exitCode: number | null signal?: string terminationReason?: 'timeout' | 'cancelled' durationMs: number stdout: string stderr: string stdoutTruncated: boolean stderrTruncated: boolean stdoutReference?: PagedOutputReference stderrReference?: PagedOutputReference } export type DirectModelProcessCapability = | { available: true shell: ProcessShellSummary } | { available: false reason: string } export type DirectModelProcessExecutionContext = { conversationId: string workspace: WorkspaceAccess signal: AbortSignal } export interface DirectModelProcessService { getCapability(): Promise execute( input: ProcessExecuteInput, context: DirectModelProcessExecutionContext ): Promise readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise releaseConversation(conversationId: string): Promise dispose(): Promise } type ProcessOutput = { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown once(event: 'error', listener: (error: Error) => void): unknown removeListener?( event: 'error', listener: (error: Error) => void ): unknown } export type DirectModelProcessChild = WaitableProcessTreeChild & { signalCode?: NodeJS.Signals | null stdout?: ProcessOutput | null stderr?: ProcessOutput | null } export type DirectModelProcessSpawn = ( executable: string, args: string[], options: { cwd: string detached: boolean env: NodeJS.ProcessEnv shell: false stdio: ['ignore', 'pipe', 'pipe'] windowsHide: true } ) => DirectModelProcessChild export type LocalDirectModelProcessServiceOptions = { outputPrefix?: string platform?: NodeJS.Platform environment?: NodeJS.ProcessEnv toolBinDirectory?: string spawnProcess?: DirectModelProcessSpawn executableExists?: (path: string) => Promise terminateProcessTree?: typeof terminateProcessTreeAndWait now?: () => number terminationWaitMs?: number } type ResolvedProcessShell = ProcessShellSummary & { executable: string } type ActiveCall = { controller: AbortController done: Promise finish: () => void } /** * Preserve the user's ordinary command environment without forwarding * credentials inherited by Electron Main for GoodBuddy or model providers. */ export function buildDirectModelProcessEnvironment( source: NodeJS.ProcessEnv = process.env, toolBinDirectory?: string, platform: NodeJS.Platform = process.platform ): NodeJS.ProcessEnv { const environment = buildCredentialFilteredUserEnvironment(source) if (toolBinDirectory) { const pathName = source.PATH !== undefined ? 'PATH' : source.Path !== undefined ? 'Path' : source.path !== undefined ? 'path' : 'PATH' const existingPath = environment[pathName] const pathDelimiter = platform === 'win32' ? ';' : ':' environment[pathName] = existingPath ? `${toolBinDirectory}${pathDelimiter}${existingPath}` : toolBinDirectory } return environment } async function defaultExecutableExists(path: string): Promise { try { await access( path, process.platform === 'win32' ? fileSystemConstants.F_OK : fileSystemConstants.X_OK ) return (await stat(path)).isFile() } catch { return false } } async function findWindowsExecutable( name: string, environment: NodeJS.ProcessEnv, exists: (path: string) => Promise ): Promise { const pathValue = environment.PATH ?? environment.Path ?? environment.path ?? '' const hasExtension = win32.extname(name) !== '' const extensions = hasExtension ? [''] : (environment.PATHEXT ?? '.COM;.EXE;.BAT;.CMD') .split(';') .filter(Boolean) for (const directory of pathValue.split(';').filter(Boolean)) { for (const extension of extensions) { const candidate = win32.join(directory, `${name}${extension}`) if (await exists(candidate)) { return candidate } } } return undefined } async function resolveProcessShell( options: LocalDirectModelProcessServiceOptions ): Promise { const platform = options.platform ?? process.platform const environment = buildDirectModelProcessEnvironment( options.environment ?? process.env, options.toolBinDirectory, platform ) const exists = options.executableExists ?? defaultExecutableExists if (platform === 'win32') { const pwsh = await findWindowsExecutable( 'pwsh', environment, exists ) if (pwsh) { return { executable: pwsh, kind: 'powershell', label: 'pwsh' } } const windowsPowerShell = (await findWindowsExecutable( 'powershell.exe', environment, exists )) ?? (await findWindowsExecutable( 'powershell', environment, exists )) return windowsPowerShell ? { executable: windowsPowerShell, kind: 'powershell', label: 'Windows PowerShell' } : undefined } if (await exists('/bin/bash')) { return { executable: '/bin/bash', kind: 'bash', label: 'Bash' } } if (await exists('/bin/sh')) { return { executable: '/bin/sh', kind: 'sh', label: 'sh' } } return undefined } function shellArguments( shell: ResolvedProcessShell, command: string ): string[] { return shell.kind === 'powershell' ? [ '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command ] : ['-c', command] } function abortError(message: string): DOMException { return new DOMException(message, 'AbortError') } async function resolveWorkingDirectory( input: { cwd?: string }, workspace: WorkspaceAccess ): Promise<{ canonicalPath: string; displayPath: string }> { if (input.cwd?.includes('\0')) { throw new Error('工作目录包含无效字符') } const initialIdentity = await workspace.getIdentity() if (initialIdentity.kind !== 'local') { throw new Error('本机进程不能在远程工作区中运行') } const canonicalRoot = await realpath( initialIdentity.canonicalDisplayPath ) if (!(await stat(canonicalRoot)).isDirectory()) { throw new Error('项目工作区不是目录') } const candidatePath = resolve(canonicalRoot, input.cwd ?? '.') const canonicalPath = await realpath(candidatePath) const canonicalDifference = relative(canonicalRoot, canonicalPath) if (!(await stat(canonicalPath)).isDirectory()) { throw new Error('工作目录不是目录') } const currentIdentity = await workspace.getIdentity() if ( currentIdentity.kind !== initialIdentity.kind || currentIdentity.id !== initialIdentity.id || currentIdentity.canonicalDisplayPath !== initialIdentity.canonicalDisplayPath ) { throw new Error('工作区在进程启动前已发生变化') } return { canonicalPath, displayPath: canonicalDifference === '..' || canonicalDifference.startsWith(`..${sep}`) || isAbsolute(canonicalDifference) ? canonicalPath : canonicalDifference || '.' } } export class LocalDirectModelProcessService implements DirectModelProcessService { private readonly activeCalls = new Map>() private readonly outputs: PagedOutputStore private readonly shell: Promise private disposed = false private disposePromise?: Promise constructor( private readonly options: LocalDirectModelProcessServiceOptions = {} ) { this.outputs = new PagedOutputStore(options.outputPrefix ?? 'process') this.shell = resolveProcessShell(options) } async getCapability(): Promise { const shell = await this.shell return shell ? { available: true, shell: { kind: shell.kind, label: shell.label } } : { available: false, reason: (this.options.platform ?? process.platform) === 'win32' ? '未找到可用的 PowerShell' : '未找到 /bin/bash 或 /bin/sh' } } async execute( input: ProcessExecuteInput, context: DirectModelProcessExecutionContext ): Promise { const parsed = processExecuteInputSchema.parse(input) return this.trackExecution(async (signal) => { const shell = await this.shell signal.throwIfAborted() if (!shell) throw new Error('当前平台没有可用的命令 Shell') return this.executeTracked({ executable: shell.executable, args: shellArguments(shell, parsed.command), cwd: parsed.cwd, timeoutMs: parsed.timeoutMs, shell }, context, signal) }, context) } async executeFile( executable: string, args: string[], context: DirectModelProcessExecutionContext, cwd?: string, timeoutMs?: number, shell: ProcessShellSummary = { kind: 'sh', label: executable } ): Promise { return this.trackExecution((signal) => this.executeTracked( { executable, args, cwd, timeoutMs, shell }, context, signal ), context) } private async trackExecution( run: (signal: AbortSignal) => Promise, context: DirectModelProcessExecutionContext ): Promise { if (this.disposed) { throw new Error('进程执行服务已关闭') } const controller = new AbortController() let finishActive!: () => void const done = new Promise((resolveDone) => { finishActive = resolveDone }) const active: ActiveCall = { controller, done, finish: finishActive } const calls = this.activeCalls.get(context.conversationId) ?? new Set() calls.add(active) this.activeCalls.set(context.conversationId, calls) const cancelFromCaller = (): void => { controller.abort(context.signal.reason) } context.signal.addEventListener('abort', cancelFromCaller, { once: true }) if (context.signal.aborted) { cancelFromCaller() } try { return await run(controller.signal) } finally { context.signal.removeEventListener('abort', cancelFromCaller) calls.delete(active) if (calls.size === 0) { this.activeCalls.delete(context.conversationId) } active.finish() } } private async executeTracked( input: { executable: string; args: string[]; cwd?: string; timeoutMs?: number; shell: ProcessShellSummary }, context: DirectModelProcessExecutionContext, signal: AbortSignal ): Promise { signal.throwIfAborted() const shell = input.shell const workingDirectory = await resolveWorkingDirectory( input, context.workspace ) signal.throwIfAborted() const platform = this.options.platform ?? process.platform const environment = buildDirectModelProcessEnvironment( this.options.environment ?? process.env, this.options.toolBinDirectory, platform ) const startedAt = (this.options.now ?? Date.now)() const stdout = await this.outputs.create( context.conversationId, MAXIMUM_OUTPUT_BYTES ) let stderr: PagedOutputWriter | undefined try { stderr = await this.outputs.create( context.conversationId, MAXIMUM_OUTPUT_BYTES ) signal.throwIfAborted() let child: DirectModelProcessChild try { child = ( this.options.spawnProcess ?? (spawn as unknown as DirectModelProcessSpawn) )(input.executable, input.args, { cwd: workingDirectory.canonicalPath, detached: platform !== 'win32', env: environment, shell: false, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) } catch (error) { throw new Error('无法启动命令 Shell', { cause: error }) } return await this.waitForProcess( child, shell, workingDirectory.displayPath, input.timeoutMs, signal, startedAt, stdout, stderr ) } catch (error) { await Promise.all([stdout.abort(), stderr?.abort()]) throw error } } private waitForProcess( child: DirectModelProcessChild, shell: ProcessShellSummary, cwd: string, timeoutMs: number | undefined, cancellationSignal: AbortSignal, startedAt: number, stdout: PagedOutputWriter, stderr: PagedOutputWriter ): Promise { child.stdout?.on('data', (chunk) => stdout.append(chunk)) child.stderr?.on('data', (chunk) => stderr.append(chunk)) return new Promise((resolveResult, rejectResult) => { let settled = false let cleanupStarted = false let failure: Error | undefined let terminationReason: 'timeout' | 'cancelled' | undefined let exitCode: number | null = null let exitSignal: string | undefined const removeListeners = (): void => { clearTimeout(timeout) cancellationSignal.removeEventListener('abort', cancel) child.stdout?.removeListener?.('error', failFromOutput) child.stderr?.removeListener?.('error', failFromOutput) } const result = async (): Promise => { const stdoutResult = await stdout.finish() const stderrResult = await stderr.finish() return { shell: { kind: shell.kind, label: shell.label }, cwd, exitCode, ...(exitSignal ? { signal: exitSignal } : {}), ...(terminationReason ? { terminationReason } : {}), durationMs: Math.max( 0, (this.options.now ?? Date.now)() - startedAt ), stdout: stdoutResult.text, stderr: stderrResult.text, stdoutTruncated: stdoutResult.truncated, stderrTruncated: stderrResult.truncated, ...(stdoutResult.reference ? { stdoutReference: stdoutResult.reference } : {}), ...(stderrResult.reference ? { stderrReference: stderrResult.reference } : {}) } } const finishResult = (): void => { if (settled) { return } settled = true removeListeners() resolveResult(result()) } const finishFailure = (error: Error): void => { if (settled) { return } settled = true removeListeners() rejectResult(error) } const finishAfterCleanup = (): void => { exitCode = child.exitCode exitSignal = child.signalCode ?? exitSignal if (terminationReason) { finishResult() } else { finishFailure(failure ?? new Error('命令进程异常终止')) } } const startCleanup = (): void => { if (cleanupStarted || settled) { return } cleanupStarted = true clearTimeout(timeout) void ( this.options.terminateProcessTree ?? terminateProcessTreeAndWait )(child, { platform: this.options.platform ?? process.platform, processGroup: (this.options.platform ?? process.platform) !== 'win32', signal: 'SIGKILL', waitMs: this.options.terminationWaitMs ?? TERMINATION_WAIT_MS }).then( finishAfterCleanup, (error: unknown) => { if (!failure && error instanceof Error) { failure = error } finishAfterCleanup() } ) } const cancel = (): void => { if (settled) { return } terminationReason = 'cancelled' startCleanup() } const failFromOutput = (error: Error): void => { if (settled || terminationReason) { return } failure = error startCleanup() } const timeout = timeoutMs === undefined ? undefined : setTimeout(() => { if (settled || terminationReason === 'cancelled') { return } terminationReason = 'timeout' startCleanup() }, timeoutMs) timeout?.unref?.() child.stdout?.once('error', failFromOutput) child.stderr?.once('error', failFromOutput) child.once('error', (error: unknown) => { if (settled || terminationReason) { return } failure = error instanceof Error ? error : new Error('命令进程发生未知错误') startCleanup() }) child.once('close', (...values: unknown[]) => { const [code, processSignal] = values exitCode = typeof code === 'number' ? code : child.exitCode exitSignal = typeof processSignal === 'string' ? processSignal : child.signalCode ?? undefined if (cleanupStarted) { return } finishResult() }) cancellationSignal.addEventListener('abort', cancel, { once: true }) if (cancellationSignal.aborted) { cancel() } }) } async releaseConversation(conversationId: string): Promise { const calls = [...(this.activeCalls.get(conversationId) ?? [])] for (const call of calls) { call.controller.abort(abortError('对话进程资源已释放')) } await Promise.all(calls.map((call) => call.done)) await this.outputs.releaseOwner(conversationId) } readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise { return this.outputs.read(ownerId, handle, cursor, limitBytes) } dispose(): Promise { this.disposePromise ??= this.disposeOnce() return this.disposePromise } private async disposeOnce(): Promise { this.disposed = true const calls = [...this.activeCalls.values()].flatMap((set) => [ ...set ]) for (const call of calls) { call.controller.abort(abortError('进程执行服务已关闭')) } await Promise.all(calls.map((call) => call.done)) await this.outputs.dispose() } }