Files
goodbuddy/src/main/agent/direct-model-process-service.ts
T
mesalogo ff746bdc1a fix: preserve native ripgrep capabilities for direct models
Replace the restricted workspace_rg parameter mapping with native args and optional cwd. Preserve rg output and exit codes, and reuse process cancellation and output_read pagination so large search results remain accessible. Ask remains read-only within the workspace; Execute uses current-account permissions.

Validation: focused regression 169 passed, 1 skipped; typecheck and lint passed. Real DeepSeek Ask and Execute pagination scenarios passed with six counted requests in the successful run; an earlier failed fixture run has no retained request count. Full-suite validation is not green: the recorded run had unrelated failures and the latest run timed out without a summary. The remote Agent does not use this local execution path.

Release note: 直连模型可使用 ripgrep 原生参数搜索文件并保留原生结果,大输出可通过 output_read 完整续读。Ask 保持工作区只读,Execute 使用当前账号权限。
2026-09-25 23:58:21 +08:00

693 lines
20 KiB
TypeScript

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<DirectModelProcessCapability>
execute(
input: ProcessExecuteInput,
context: DirectModelProcessExecutionContext
): Promise<ProcessExecuteResult>
readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise<PagedOutputPage>
releaseConversation(conversationId: string): Promise<void>
dispose(): Promise<void>
}
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<boolean>
terminateProcessTree?: typeof terminateProcessTreeAndWait
now?: () => number
terminationWaitMs?: number
}
type ResolvedProcessShell = ProcessShellSummary & {
executable: string
}
type ActiveCall = {
controller: AbortController
done: Promise<void>
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<boolean> {
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<boolean>
): Promise<string | undefined> {
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<ResolvedProcessShell | undefined> {
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<string, Set<ActiveCall>>()
private readonly outputs: PagedOutputStore
private readonly shell: Promise<ResolvedProcessShell | undefined>
private disposed = false
private disposePromise?: Promise<void>
constructor(
private readonly options: LocalDirectModelProcessServiceOptions = {}
) {
this.outputs = new PagedOutputStore(options.outputPrefix ?? 'process')
this.shell = resolveProcessShell(options)
}
async getCapability(): Promise<DirectModelProcessCapability> {
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<ProcessExecuteResult> {
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<ProcessExecuteResult> {
return this.trackExecution((signal) => this.executeTracked(
{ executable, args, cwd, timeoutMs, shell }, context, signal
), context)
}
private async trackExecution(
run: (signal: AbortSignal) => Promise<ProcessExecuteResult>,
context: DirectModelProcessExecutionContext
): Promise<ProcessExecuteResult> {
if (this.disposed) {
throw new Error('进程执行服务已关闭')
}
const controller = new AbortController()
let finishActive!: () => void
const done = new Promise<void>((resolveDone) => {
finishActive = resolveDone
})
const active: ActiveCall = {
controller,
done,
finish: finishActive
}
const calls =
this.activeCalls.get(context.conversationId) ??
new Set<ActiveCall>()
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<ProcessExecuteResult> {
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<ProcessExecuteResult> {
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<ProcessExecuteResult> => {
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<void> {
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<PagedOutputPage> {
return this.outputs.read(ownerId, handle, cursor, limitBytes)
}
dispose(): Promise<void> {
this.disposePromise ??= this.disposeOnce()
return this.disposePromise
}
private async disposeOnce(): Promise<void> {
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()
}
}