fix: clean up OpenCode streams and allow external working directories

Completed OpenCode requests left their event streams open across repeated and
parallel conversations. Each request now closes its own subscription on exit
without cancelling peer sessions, and cancellation preserves its original cause.

Direct-model Execute commands no longer restrict cwd to the workspace. Absolute
paths, parent-relative paths, and symlinks to external directories are accepted.
The workspace remains the default and relative-path base; Ask stays read-only,
and local execution binding, directory validation, and process cleanup remain.

Add real OpenCode lifecycle and real Shell tool-loop regressions, plus matching
feature documentation. Focused checks: 182 passed, 1 skipped; typecheck and lint
passed. Full shared-worktree suite: 3542 passed, 57 skipped, 1 unrelated sidebar
heading failure, reproduced separately and documented.

Release note: 修复 OpenCode 连续及并行对话结束后事件连接未释放的问题;直连模型 Execute 命令现在可直接指定工作区外的绝对路径、相对路径或符号链接目录,Ask 只读边界保持不变。
This commit is contained in:
mesalogo
2026-09-06 19:53:42 +08:00
parent 7003141e56
commit b03c25685c
15 changed files with 394 additions and 100 deletions
@@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events'
import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join, relative } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LocalWorkspaceAccess } from '../workspace'
import {
@@ -110,10 +110,10 @@ describe('LocalDirectModelProcessService', () => {
).rejects.toThrow()
await expect(
service.execute(
{ command: 'echo ok', cwd: join(process.cwd(), 'absolute') },
{ command: 'echo ok', cwd: 'invalid\0directory' },
context
)
).rejects.toThrow('相对于工作区')
).rejects.toThrow('无效字符')
expect(spawnProcess).not.toHaveBeenCalled()
await service.dispose()
})
@@ -198,68 +198,82 @@ describe('LocalDirectModelProcessService', () => {
await unavailable.dispose()
})
it('resolves only existing workspace directories and rejects symlink escapes', async () => {
const { root, access } = await workspace()
const child = new FakeChild()
const { service, spawnProcess } = fakeService(child)
await mkdir(join(root, 'nested'))
const execution = service.execute(
{ command: 'pwd', cwd: 'nested' },
{
conversationId: 'cwd',
workspace: access,
signal: new AbortController().signal
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'
)
}
)
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
expect(spawnProcess.mock.calls[0]?.[2].cwd).toBe(
await realpath(join(root, 'nested'))
)
child.close(0)
await expect(execution).resolves.toMatchObject({ cwd: 'nested' })
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)
await expect(
service.execute(
{ command: 'pwd', cwd: '..' },
const execution = service.execute(
{ command: 'pwd', cwd },
{
conversationId: 'escape',
conversationId: 'cwd',
workspace: access,
signal: new AbortController().signal
}
)
).rejects.toThrow('不能超出')
const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-outside-'))
temporaryDirectories.push(outside)
const link = join(root, 'outside-link')
try {
await symlink(
outside,
link,
process.platform === 'win32' ? 'junction' : 'dir'
)
await expect(
service.execute(
{ command: 'pwd', cwd: 'outside-link' },
{
conversationId: 'symlink',
workspace: access,
signal: new AbortController().signal
}
)
).rejects.toThrow('符号链接')
} catch (error) {
if (
!(
error instanceof Error &&
'code' in error &&
error.code === 'EPERM'
)
) {
throw error
}
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()
})