Files
goodbuddy/src/main/agent/direct-model-process-service.test.ts
T

544 lines
17 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LocalWorkspaceAccess } from '../workspace'
import {
LocalDirectModelProcessService,
buildDirectModelProcessEnvironment,
type DirectModelProcessChild,
type DirectModelProcessSpawn
} from './direct-model-process-service'
class FakeChild extends EventEmitter implements DirectModelProcessChild {
exitCode: number | null = null
signalCode: NodeJS.Signals | null = null
pid = 4321
killed = false
readonly stdout = new EventEmitter()
readonly stderr = new EventEmitter()
readonly kill = vi.fn(() => {
this.killed = true
return true
})
close(code: number | null, signal?: NodeJS.Signals): void {
this.exitCode = code
this.signalCode = signal ?? null
this.emit('close', code, signal ?? null)
}
}
const temporaryDirectories: string[] = []
afterEach(async () => {
vi.useRealTimers()
for (const directory of temporaryDirectories.splice(0)) {
await import('node:fs/promises').then(({ rm }) =>
rm(directory, { recursive: true, force: true })
)
}
})
async function workspace(): Promise<{
root: string
access: LocalWorkspaceAccess
}> {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-process-'))
temporaryDirectories.push(root)
return { root, access: new LocalWorkspaceAccess(root) }
}
function fakeService(
child: FakeChild,
options: {
platform?: NodeJS.Platform
environment?: NodeJS.ProcessEnv
now?: () => number
terminateProcessTree?: (
child: DirectModelProcessChild,
options?: Record<string, unknown>
) => Promise<void>
executableExists?: (path: string) => Promise<boolean>
} = {}
) {
const spawnProcess = vi.fn<DirectModelProcessSpawn>(() => child)
const service = new LocalDirectModelProcessService({
platform: options.platform ?? 'linux',
environment: options.environment ?? { PATH: '/usr/bin' },
executableExists:
options.executableExists ??
(async (path) => path === '/bin/bash'),
spawnProcess,
terminateProcessTree:
options.terminateProcessTree as never,
now: options.now
})
return { service, spawnProcess }
}
describe('LocalDirectModelProcessService', () => {
it('strictly validates command, cwd, timeout, and unknown fields', async () => {
const { access } = await workspace()
const child = new FakeChild()
const { service, spawnProcess } = fakeService(child)
const context = {
conversationId: 'validation',
workspace: access,
signal: new AbortController().signal
}
await expect(
service.execute({ command: ' ' }, context)
).rejects.toThrow()
await expect(
service.execute(
{ command: 'echo ok', timeoutMs: 999 },
context
)
).rejects.toThrow()
await expect(
service.execute(
{
command: 'echo ok',
unexpected: true
} as never,
context
)
).rejects.toThrow()
await expect(
service.execute(
{ command: 'echo ok', cwd: 'invalid\0directory' },
context
)
).rejects.toThrow('无效字符')
expect(spawnProcess).not.toHaveBeenCalled()
await service.dispose()
})
it('selects PowerShell in order and uses the direct noninteractive launch contract', async () => {
const { access } = await workspace()
const child = new FakeChild()
const available = new Set(['C:\\tools\\powershell.exe'])
const { service, spawnProcess } = fakeService(child, {
platform: 'win32',
environment: {
Path: 'C:\\tools',
PATHEXT: '.EXE'
},
executableExists: async (path) => available.has(path)
})
expect(await service.getCapability()).toEqual({
available: true,
shell: {
kind: 'powershell',
label: 'Windows PowerShell'
}
})
const execution = service.execute(
{ command: 'Write-Output ready' },
{
conversationId: 'powershell',
workspace: access,
signal: new AbortController().signal
}
)
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
child.close(0)
await expect(execution).resolves.toMatchObject({
shell: {
kind: 'powershell',
label: 'Windows PowerShell'
},
cwd: '.',
exitCode: 0
})
expect(spawnProcess).toHaveBeenCalledWith(
'C:\\tools\\powershell.exe',
[
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-Command',
'Write-Output ready'
],
expect.objectContaining({
detached: false,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
)
await service.dispose()
})
it('falls back from Bash to sh and reports unavailable shells', async () => {
const sh = new LocalDirectModelProcessService({
platform: 'linux',
executableExists: async (path) => path === '/bin/sh'
})
const unavailable = new LocalDirectModelProcessService({
platform: 'linux',
executableExists: async () => false
})
await expect(sh.getCapability()).resolves.toEqual({
available: true,
shell: { kind: 'sh', label: 'sh' }
})
await expect(unavailable.getCapability()).resolves.toEqual({
available: false,
reason: expect.stringContaining('/bin/bash')
})
await sh.dispose()
await unavailable.dispose()
})
it.each(['relative', 'absolute', 'outside', 'parent', 'symlink'])(
'resolves an existing %s working directory without workspace containment',
async (kind) => {
const { root, access } = await workspace()
const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-outside-'))
temporaryDirectories.push(outside)
const nested = join(root, 'nested')
await mkdir(nested)
const link = join(root, 'outside-link')
if (kind === 'symlink') {
await symlink(
outside,
link,
process.platform === 'win32' ? 'junction' : 'dir'
)
}
const cwd =
kind === 'relative'
? 'nested'
: kind === 'absolute'
? nested
: kind === 'outside'
? outside
: kind === 'parent'
? relative(root, outside)
: 'outside-link'
const expectedPath = await realpath(
kind === 'relative' || kind === 'absolute' ? nested : outside
)
const child = new FakeChild()
const { service, spawnProcess } = fakeService(child)
const execution = service.execute(
{ command: 'pwd', cwd },
{
conversationId: 'cwd',
workspace: access,
signal: new AbortController().signal
}
)
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
expect(spawnProcess.mock.calls[0]?.[2].cwd).toBe(expectedPath)
child.close(0)
await expect(execution).resolves.toMatchObject({
cwd:
kind === 'relative' || kind === 'absolute'
? 'nested'
: expectedPath
})
await service.dispose()
}
)
it('rejects missing working directories and files before starting a process', async () => {
const { root, access } = await workspace()
const file = join(root, 'file.txt')
await writeFile(file, 'not a directory')
const { service, spawnProcess } = fakeService(new FakeChild())
const context = {
conversationId: 'invalid-cwd',
workspace: access,
signal: new AbortController().signal
}
await expect(
service.execute(
{ command: 'pwd', cwd: join(root, 'missing') },
context
)
).rejects.toThrow()
await expect(
service.execute(
{ command: 'pwd', cwd: file },
context
)
).rejects.toThrow('不是目录')
expect(spawnProcess).not.toHaveBeenCalled()
await service.dispose()
})
it('returns nonzero exits with bounded previews and retains full output for paging', async () => {
const { access } = await workspace()
const child = new FakeChild()
let now = 1_000
const { service, spawnProcess } = fakeService(child, {
now: () => now
})
const execution = service.execute(
{ command: 'large output' },
{
conversationId: 'output',
workspace: access,
signal: new AbortController().signal
}
)
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
child.stdout.emit(
'data',
Buffer.concat([
Buffer.alloc(48 * 1024, 'A'),
Buffer.alloc(10, 'M'),
Buffer.alloc(48 * 1024, 'Z')
])
)
child.stderr.emit('data', 'ordinary error output')
now = 1_125
child.close(7)
const result = await execution
expect(result.exitCode).toBe(7)
expect(result.durationMs).toBe(125)
expect(result.stdoutTruncated).toBe(true)
expect(result.stdout).toBe(
'A'.repeat(48 * 1024) + 'M'.repeat(10) + 'Z'.repeat(48 * 1024 - 10)
)
expect(result.stderr).toBe('ordinary error output')
expect(result.stderrTruncated).toBe(false)
expect(result.stderrReference).toBeUndefined()
const reference = result.stdoutReference!
expect(reference.totalBytes).toBe(96 * 1024 + 10)
await expect(service.readOutput('output', reference.handle, reference.nextCursor)).resolves.toMatchObject({
content: 'Z'.repeat(10), eof: true, nextCursor: reference.totalBytes
})
await expect(service.readOutput('other', reference.handle)).rejects.toThrow()
await service.releaseConversation('output')
await expect(service.readOutput('output', reference.handle)).rejects.toThrow()
await service.dispose()
})
it('filters application and provider credentials while preserving ordinary variables and prepending tool PATH', () => {
expect(
buildDirectModelProcessEnvironment(
{
PATH: '/usr/bin',
HOME: '/home/person',
CUSTOM_TOKEN: 'ordinary-user-value',
GOODBUDDY_MODEL_API_KEY: 'product-secret',
FACTORY_API_KEY: 'factory-secret',
ANTHROPIC_API_KEY: 'provider-secret',
DEEPSEEK_API_KEY: 'provider-secret'
},
'/managed/bin',
'linux'
)
).toEqual({
PATH: '/managed/bin:/usr/bin',
HOME: '/home/person',
CUSTOM_TOKEN: 'ordinary-user-value'
})
})
it('returns timeout and cancellation results only after owned-tree cleanup', async () => {
vi.useFakeTimers()
const { access } = await workspace()
const timeoutChild = new FakeChild()
const timeoutCleanup = vi.fn(async () => {
timeoutChild.exitCode = null
timeoutChild.signalCode = 'SIGKILL'
timeoutChild.emit('close', null, 'SIGKILL')
})
const timeoutHarness = fakeService(timeoutChild, {
terminateProcessTree: timeoutCleanup
})
const timed = timeoutHarness.service.execute(
{ command: 'wait', timeoutMs: 1_000 },
{
conversationId: 'timeout',
workspace: access,
signal: new AbortController().signal
}
)
await vi.waitFor(() =>
expect(timeoutHarness.spawnProcess).toHaveBeenCalledOnce()
)
await vi.advanceTimersByTimeAsync(1_000)
await expect(timed).resolves.toMatchObject({
terminationReason: 'timeout',
exitCode: null,
signal: 'SIGKILL'
})
expect(timeoutCleanup).toHaveBeenCalledWith(
timeoutChild,
expect.objectContaining({
platform: 'linux',
processGroup: true,
signal: 'SIGKILL'
})
)
const cancelledChild = new FakeChild()
const cancelCleanup = vi.fn(async () => {
cancelledChild.signalCode = 'SIGKILL'
cancelledChild.emit('close', null, 'SIGKILL')
})
const cancelledHarness = fakeService(cancelledChild, {
terminateProcessTree: cancelCleanup
})
const controller = new AbortController()
const cancelled = cancelledHarness.service.execute(
{ command: 'wait' },
{
conversationId: 'cancelled',
workspace: access,
signal: controller.signal
}
)
await vi.waitFor(() =>
expect(cancelledHarness.spawnProcess).toHaveBeenCalledOnce()
)
controller.abort()
await expect(cancelled).resolves.toMatchObject({
terminationReason: 'cancelled',
signal: 'SIGKILL'
})
expect(cancelCleanup).toHaveBeenCalledOnce()
await timeoutHarness.service.dispose()
await cancelledHarness.service.dispose()
})
it('releaseConversation and dispose cancel only their tracked active calls', async () => {
const { access } = await workspace()
const firstChild = new FakeChild()
const secondChild = new FakeChild()
const children = [firstChild, secondChild]
const spawnProcess = vi.fn<DirectModelProcessSpawn>(
() => children.shift()!
)
const terminated: DirectModelProcessChild[] = []
const service = new LocalDirectModelProcessService({
platform: 'linux',
executableExists: async (path) => path === '/bin/bash',
spawnProcess,
terminateProcessTree: async (child) => {
terminated.push(child)
const fakeChild = child as FakeChild
fakeChild.signalCode = 'SIGKILL'
fakeChild.emit('close', null, 'SIGKILL')
}
})
const run = (conversationId: string) =>
service.execute(
{ command: 'wait' },
{
conversationId,
workspace: access,
signal: new AbortController().signal
}
)
const first = run('first')
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
const second = run('second')
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledTimes(2))
firstChild.stdout.emit('data', 'x'.repeat(120000))
secondChild.stderr.emit('data', 'y'.repeat(120000))
await service.releaseConversation('first')
expect(terminated).toHaveLength(1)
await expect(first).resolves.toMatchObject({
terminationReason: 'cancelled'
})
await expect(service.readOutput('first', (await first).stdoutReference!.handle)).rejects.toThrow()
const replacementChild = new FakeChild()
children.push(replacementChild)
const replacement = run('first')
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledTimes(3))
replacementChild.exitCode = 0
replacementChild.emit('close', 0, null)
await expect(replacement).resolves.toMatchObject({
exitCode: 0
})
await service.dispose()
expect(terminated).toHaveLength(2)
expect(terminated.includes(firstChild)).toBe(true)
expect(terminated.includes(secondChild)).toBe(true)
await expect(second).resolves.toMatchObject({
terminationReason: 'cancelled'
})
await expect(service.readOutput('second', (await second).stderrReference!.handle)).rejects.toThrow()
await service.dispose()
})
it('runs a real command in the workspace with ignored stdin and piped output', async () => {
const { root, access } = await workspace()
await writeFile(join(root, 'marker.txt'), 'ready', 'utf8')
const service = new LocalDirectModelProcessService()
const capability = await service.getCapability()
if (!capability.available) {
await service.dispose()
return
}
const command =
process.platform === 'win32'
? "Get-Content -Raw 'marker.txt'; [Console]::Error.Write('warn'); exit 3"
: "cat marker.txt; printf warn >&2; exit 3"
const result = await service.execute(
{ command },
{
conversationId: 'real-command',
workspace: access,
signal: new AbortController().signal
}
)
expect(result.exitCode).toBe(3)
expect(result.cwd).toBe('.')
expect(result.stdout).toContain('ready')
expect(result.stderr).toContain('warn')
await service.dispose()
})
it('pages complete stdout and stderr from a real shell', async () => {
const { access } = await workspace()
const service = new LocalDirectModelProcessService()
try {
expect((await service.getCapability()).available).toBe(true)
const command = process.platform === 'win32'
? "[Console]::Out.Write(('x' * 120000) + 'stdout-end'); [Console]::Error.Write(('y' * 120000) + 'stderr-end')"
: "printf '%120000sstdout-end' '' | tr ' ' x; printf '%120000sstderr-end' '' | tr ' ' y >&2"
const result = await service.execute({ command }, {
conversationId: 'real-pages', workspace: access,
signal: new AbortController().signal
})
for (const [preview, reference, expected] of [
[result.stdout, result.stdoutReference, 'x'.repeat(120000) + 'stdout-end'],
[result.stderr, result.stderrReference, 'y'.repeat(120000) + 'stderr-end']
] as const) {
expect(reference).toBeDefined()
let output = preview
let cursor = reference!.nextCursor
while (cursor < reference!.totalBytes) {
const page = await service.readOutput('real-pages', reference!.handle, cursor, 4096)
output += page.content
cursor = page.nextCursor
}
expect(output).toBe(expected)
}
await service.dispose()
await expect(service.readOutput('real-pages', result.stdoutReference!.handle)).rejects.toThrow()
} finally {
await service.dispose()
}
})
})