feat: improve desktop reliability and customization

Address reliability and consistency gaps across Agent Runtimes, persistence, settings, Knowledge, Magic Notes, Smart Heartbeat, and the download site. Runtime processes now have bounded lifecycle cleanup and atomic configuration rollback, while model packages and persisted mutations recover safely.

Add a configurable global shortcut, protect unsaved work, improve modal and keyboard behavior, localize the built-in project without rewriting stored data, and lazy-load heavy renderer routes under enforced bundle budgets. Align project forms and disabled controls with shared typography and interaction states, and strengthen website release metadata validation and navigation accessibility.

Release note: 修复 Runtime、设置、知识库、魔法笔记与智能心跳中的可靠性和交互一致性问题;新增可配置全局快捷键,改进无障碍与加载性能,并强化官网下载校验。
This commit is contained in:
mesalogo
2026-08-20 10:11:06 +08:00
parent 20c15f74c6
commit f44a0dc907
129 changed files with 15511 additions and 2112 deletions
@@ -0,0 +1,195 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import {
requestProcessTreeTermination,
terminateProcessTreeAndWait,
waitForProcessExit,
type WaitableProcessTreeChild
} from './child-process-termination'
function fakeChild(
pid = 42
): WaitableProcessTreeChild & EventEmitter {
const child =
new EventEmitter() as WaitableProcessTreeChild & EventEmitter
child.exitCode = null
child.pid = pid
child.kill = vi.fn()
return child
}
describe('child process tree termination', () => {
it('uses taskkill /T /F on Windows and bounds both exit waits', async () => {
vi.useFakeTimers()
try {
const child = fakeChild(314)
const killer = fakeChild(315)
killer.unref = vi.fn()
const spawnMock = vi.fn(() => killer)
const termination = terminateProcessTreeAndWait(child, {
platform: 'win32',
spawn: spawnMock,
waitMs: 25
})
await vi.advanceTimersByTimeAsync(25)
await vi.advanceTimersByTimeAsync(25)
await expect(termination).resolves.toBeUndefined()
expect(spawnMock).toHaveBeenCalledWith(
'taskkill.exe',
['/PID', '314', '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
expect(killer.unref).toHaveBeenCalledOnce()
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
} finally {
vi.useRealTimers()
}
})
it('falls back to direct termination when Windows taskkill fails', async () => {
const child = fakeChild(314)
const killer = fakeChild(315)
const spawnMock = vi.fn(() => killer)
const termination = terminateProcessTreeAndWait(child, {
platform: 'win32',
spawn: spawnMock,
waitMs: 1_000
})
killer.exitCode = 1
killer.emit('close', 1, null)
await Promise.resolve()
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
child.exitCode = 0
child.emit('close', 0, null)
await expect(termination).resolves.toBeUndefined()
})
it('terminates a detached POSIX process group with the requested signal', () => {
const child = fakeChild(2718)
const killProcess = vi.fn()
requestProcessTreeTermination(child, {
platform: 'linux',
processGroup: true,
signal: 'SIGKILL',
killProcess
})
expect(killProcess).toHaveBeenCalledWith(-2718, 'SIGKILL')
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('falls back to the direct child when POSIX group termination fails', () => {
const child = fakeChild(2718)
requestProcessTreeTermination(child, {
platform: 'linux',
processGroup: true,
killProcess: vi.fn(() => {
throw new Error('not a group leader')
})
})
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
})
it('resolves exit waiting immediately on close', async () => {
const child = fakeChild()
const waiting = waitForProcessExit(child, 1_000)
child.emit('close', 0, null)
await expect(waiting).resolves.toBeUndefined()
})
it('does not terminate a child already marked as killed', () => {
const child = fakeChild()
child.killed = true
const spawnMock = vi.fn()
const killProcess = vi.fn()
expect(
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: spawnMock,
killProcess
})
).toBeUndefined()
expect(spawnMock).not.toHaveBeenCalled()
expect(killProcess).not.toHaveBeenCalled()
expect(child.kill).not.toHaveBeenCalled()
})
it('supports utility-process handles without an exitCode', () => {
const child = {
killed: false,
pid: 99,
kill: vi.fn()
}
const killer = fakeChild(100)
const spawnMock = vi.fn(() => killer)
expect(
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: spawnMock
})
).toBe(killer)
expect(spawnMock).toHaveBeenCalledWith(
'taskkill.exe',
['/PID', '99', '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
})
it('falls back asynchronously for a synchronous Windows caller', async () => {
vi.useFakeTimers()
try {
const child = {
pid: 99,
kill: vi.fn()
}
const killer = fakeChild(100)
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: vi.fn(() => killer),
signal: 'SIGKILL',
waitMs: 25
})
await vi.advanceTimersByTimeAsync(25)
expect(child.kill).toHaveBeenCalledOnce()
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
} finally {
vi.useRealTimers()
}
})
it('does not directly kill after successful Windows tree termination', () => {
const child = {
pid: 99,
kill: vi.fn()
}
const killer = fakeChild(100)
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: vi.fn(() => killer)
})
killer.exitCode = 0
killer.emit('close', 0, null)
expect(child.kill).not.toHaveBeenCalled()
})
})
+193
View File
@@ -0,0 +1,193 @@
import spawn from 'cross-spawn'
export type ProcessTreeChild = {
exitCode?: number | null
killed?: boolean
pid?: number
kill: (signal?: NodeJS.Signals) => unknown
unref?: () => unknown
}
export type WaitableProcessTreeChild = ProcessTreeChild & {
exitCode: number | null
once: (
event: 'close' | 'error',
listener: (...args: unknown[]) => void
) => unknown
removeListener?: (
event: 'close' | 'error',
listener: (...args: unknown[]) => void
) => unknown
}
export type ProcessTreeSpawn = (
command: string,
args: string[],
options: {
shell: false
stdio: 'ignore'
windowsHide: true
}
) => WaitableProcessTreeChild
export type ProcessGroupKill = (
pid: number,
signal: NodeJS.Signals
) => unknown
export type ProcessTreeTerminationOptions = {
platform?: NodeJS.Platform
spawn?: ProcessTreeSpawn
killProcess?: ProcessGroupKill
processGroup?: boolean
signal?: NodeJS.Signals
waitMs?: number
}
const DEFAULT_EXIT_WAIT_MS = 2_000
type ProcessExitResult = 'closed' | 'error' | 'timeout'
function monitorWindowsKiller(
killer: WaitableProcessTreeChild,
child: ProcessTreeChild,
signal: NodeJS.Signals,
waitMs: number
): void {
let settled = false
const fallback = (): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
killer.removeListener?.('close', onClose)
killer.removeListener?.('error', onError)
if (
!child.killed &&
(child.exitCode === undefined || child.exitCode === null)
) {
child.kill(signal)
}
}
const onClose = (): void => {
if (killer.exitCode === 0) {
settled = true
clearTimeout(timer)
killer.removeListener?.('error', onError)
return
}
fallback()
}
const onError = (): void => fallback()
const timer = setTimeout(fallback, waitMs)
timer.unref?.()
killer.once('close', onClose)
killer.once('error', onError)
}
function waitForProcessExitResult(
child: WaitableProcessTreeChild,
waitMs: number
): Promise<ProcessExitResult> {
if (child.exitCode !== null) {
return Promise.resolve('closed')
}
return new Promise((resolve) => {
const finish = (result: ProcessExitResult): void => {
clearTimeout(timer)
child.removeListener?.('close', onClose)
child.removeListener?.('error', onError)
resolve(result)
}
const onClose = (): void => finish('closed')
const onError = (): void => finish('error')
const timer = setTimeout(
() => finish('timeout'),
waitMs
)
timer.unref?.()
child.once('close', onClose)
child.once('error', onError)
})
}
export function waitForProcessExit(
child: WaitableProcessTreeChild,
waitMs = DEFAULT_EXIT_WAIT_MS
): Promise<void> {
return waitForProcessExitResult(child, waitMs).then(() => undefined)
}
export function requestProcessTreeTermination(
child: ProcessTreeChild,
options: ProcessTreeTerminationOptions = {}
): WaitableProcessTreeChild | undefined {
if (
(child.exitCode !== undefined && child.exitCode !== null) ||
child.killed
) {
return undefined
}
const platform = options.platform ?? process.platform
const signal = options.signal ?? 'SIGTERM'
if (platform === 'win32' && child.pid) {
try {
const killer = (options.spawn ?? spawn)(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref?.()
monitorWindowsKiller(
killer,
child,
signal,
options.waitMs ?? DEFAULT_EXIT_WAIT_MS
)
return killer
} catch {
child.kill(signal)
return undefined
}
}
if (options.processGroup && child.pid) {
try {
;(options.killProcess ?? process.kill)(-child.pid, signal)
if (child.exitCode === null) {
child.kill(signal)
}
return undefined
} catch {
// The child may not be a process-group leader. Fall back to the
// direct handle so cleanup is never weakened by that assumption.
}
}
child.kill(signal)
return undefined
}
export async function terminateProcessTreeAndWait(
child: WaitableProcessTreeChild,
options: ProcessTreeTerminationOptions = {}
): Promise<void> {
if (child.exitCode !== null) {
return
}
const exited = waitForProcessExit(
child,
options.waitMs ?? DEFAULT_EXIT_WAIT_MS
)
const killer = requestProcessTreeTermination(child, options)
if (killer) {
await waitForProcessExitResult(
killer,
options.waitMs ?? DEFAULT_EXIT_WAIT_MS
)
}
await exited
}
@@ -1124,6 +1124,263 @@ describe('ContinueHostAdapter', () => {
).rejects.toThrow('流式事件超过安全限制')
})
it.each([
{
label: 'event count',
limits: {
maximumStreamEvents: 1,
maximumStreamEventBytes: 10_000
}
},
{
label: 'event bytes',
limits: {
maximumStreamEvents: 10,
maximumStreamEventBytes: 60
}
}
])(
'enforces cumulative streamed $label across state polls',
async ({ limits }) => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: { history: [] },
isProcessing: stateRequests > 1,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEvents:
stateRequests > 1
? [{ type: 'text', delta: '1234567890' }]
: []
})
}
return Response.json({})
})
)
const forwarded: unknown[] = []
const adapter = new ContinueHostAdapter(
{
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: () => ({
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}),
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
},
{
...limits,
maximumToolCalls: 100,
terminateProcessTree: vi.fn().mockResolvedValue(undefined)
}
)
await expect(
adapter.run(
'hello',
new AbortController().signal,
async () => 'deny',
{
onEvent: (event) => {
forwarded.push(event)
}
}
)
).rejects.toThrow('流式事件超过安全限制')
expect(forwarded).toEqual([
{ type: 'text', delta: '1234567890' }
])
}
)
it('enforces cumulative unique tool calls before forwarding a later batch', async () => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: { history: [] },
isProcessing: stateRequests > 1,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEvents:
stateRequests > 1
? [
{
type: 'tool',
callId: `call-${stateRequests}`,
name: 'Bash',
state: 'running'
}
]
: []
})
}
return Response.json({})
})
)
const forwarded: unknown[] = []
const adapter = new ContinueHostAdapter(
{
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: () => ({
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}),
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
},
{
maximumStreamEvents: 10,
maximumStreamEventBytes: 10_000,
maximumToolCalls: 1,
terminateProcessTree: vi.fn().mockResolvedValue(undefined)
}
)
await expect(
adapter.run(
'hello',
new AbortController().signal,
async () => 'deny',
{
onEvent: (event) => {
forwarded.push(event)
}
}
)
).rejects.toThrow('工具调用超过 100 个')
expect(forwarded).toHaveLength(1)
expect(forwarded[0]).toMatchObject({
type: 'tool',
tool: { callId: 'call-2' }
})
})
it('awaits bounded process cleanup before deleting the run directory', async () => {
const distribution = await createDistribution()
let globalDirectory = ''
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: {
history:
stateRequests === 1
? []
: [
{
message: {
role: 'assistant',
content: 'CLEANUP_OK'
}
}
]
},
isProcessing: false,
messageQueueLength: 0,
pendingPermission: null
})
}
return Response.json({})
})
)
let releaseTermination!: () => void
const terminateProcessTree = vi.fn(
() =>
new Promise<void>((resolve) => {
releaseTermination = resolve
})
)
const adapter = new ContinueHostAdapter(
{
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: (_entry, _args, options) => {
globalDirectory =
options.env.CONTINUE_GLOBAL_DIR ?? ''
return {
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}
},
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
},
{
maximumStreamEvents: 10,
maximumStreamEventBytes: 10_000,
maximumToolCalls: 10,
terminateProcessTree
}
)
const run = adapter.run(
'hello',
new AbortController().signal,
async () => 'deny'
)
await vi.waitFor(() =>
expect(terminateProcessTree).toHaveBeenCalledOnce()
)
expect(globalDirectory).toBeTruthy()
expect(existsSync(globalDirectory)).toBe(true)
releaseTermination()
await expect(run).resolves.toEqual({ text: 'CLEANUP_OK' })
expect(existsSync(globalDirectory)).toBe(false)
})
it('uses auto mode and returns audit metadata for agent tools', async () => {
const distribution = await createDistribution()
let launchArgs: string[] = []
+105 -33
View File
@@ -50,6 +50,7 @@ import { stageRuntimeSkillPackages } from './runtime-skill-packages'
import { readBoundedResponseText } from './bounded-response'
import { scopedReadToolNames } from '../../shared/scoped-data-tools'
import { readBoundedFile } from '../workspace-file-access'
import { terminateProcessTreeAndWait } from './child-process-termination'
const supportedVersion = '1.5.47'
const supportedBundleHashes = new Set([
@@ -65,6 +66,7 @@ const maximumConfiguredRules = runtimeNativeInventoryLimits.rules
const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts
const maximumStreamEvents = 5_000
const maximumStreamEventBytes = 2 * 1024 * 1024
const maximumToolCalls = 100
const maximumExecutionMilliseconds = 10 * 60_000
const knowledgeMcpName = 'goodbuddy-knowledge'
const customMcpName = 'goodbuddy-custom-mcp'
@@ -239,6 +241,13 @@ export type ContinueHostAdapterOptions = {
skillPackages?: RuntimeSkillPackage[]
}
export type ContinueHostAdapterDependencies = {
terminateProcessTree: typeof terminateProcessTreeAndWait
maximumStreamEvents: number
maximumStreamEventBytes: number
maximumToolCalls: number
}
export type ContinueHostRunOptions = {
workMode?: 'ask' | 'execute'
images?: AgentImage[]
@@ -504,8 +513,12 @@ export type ContinueHostChild = {
) => unknown
} | null
once: (
event: 'error',
listener: (error: Error) => void
event: 'error' | 'close',
listener: (error: Error | number | null) => void
) => unknown
removeListener?: (
event: 'error' | 'close',
listener: (error: Error | number | null) => void
) => unknown
kill: (signal?: NodeJS.Signals) => unknown
}
@@ -792,6 +805,10 @@ function extractUsageDelta(
export class ContinueHostAdapter {
private readonly children = new Set<ContinueHostChild>()
private readonly childTerminations = new WeakMap<
ContinueHostChild,
Promise<void>
>()
private readonly pendingQuestions = new Map<
string,
{
@@ -802,7 +819,20 @@ export class ContinueHostAdapter {
>()
private preparation?: Promise<PreparedHost>
constructor(private readonly options: ContinueHostAdapterOptions) {}
private readonly dependencies: ContinueHostAdapterDependencies
constructor(
private readonly options: ContinueHostAdapterOptions,
dependencies: Partial<ContinueHostAdapterDependencies> = {}
) {
this.dependencies = {
terminateProcessTree: terminateProcessTreeAndWait,
maximumStreamEvents,
maximumStreamEventBytes,
maximumToolCalls,
...dependencies
}
}
private async prepare(): Promise<PreparedHost> {
if (!isAbsolute(this.options.cacheRoot)) {
@@ -1515,17 +1545,20 @@ export class ContinueHostAdapter {
child.stderr?.on('data', (chunk: Buffer | string) => {
stderrBytes += Buffer.byteLength(chunk)
if (stderrBytes > 64 * 1024) {
this.terminate(child)
void this.terminate(child)
}
})
const abort = (): void => {
this.terminate(child)
void this.terminate(child)
}
signal.addEventListener('abort', abort, { once: true })
let observedTools: ContinueHostTool[] = []
const reportedQuestionIds = new Set<string>()
let streamedText = false
let streamEventCount = 0
let streamEventBytes = 0
const observedToolCallIds = new Set<string>()
let executionTimeoutSignal: AbortSignal | undefined
try {
const initialState = await this.waitForStartup(
@@ -1585,17 +1618,55 @@ export class ContinueHostAdapter {
if (state.goodbuddyEventsOverflow) {
throw new Error('Continue 宿主流式事件超过安全限制')
}
const streamEventBytes = Buffer.byteLength(
JSON.stringify(state.goodbuddyEvents ?? [])
const streamEvents = state.goodbuddyEvents ?? []
const batchStreamEventBytes = Buffer.byteLength(
JSON.stringify(streamEvents)
)
if (streamEventBytes > maximumStreamEventBytes) {
const nextStreamEventCount =
streamEventCount + streamEvents.length
const nextStreamEventBytes =
streamEventBytes + batchStreamEventBytes
const nextToolCallIds = new Set(observedToolCallIds)
for (const event of streamEvents) {
if (event.type === 'tool') {
nextToolCallIds.add(event.callId)
}
}
if (
nextStreamEventCount >
this.dependencies.maximumStreamEvents ||
nextStreamEventBytes >
this.dependencies.maximumStreamEventBytes
) {
throw new Error('Continue 宿主流式事件超过安全限制')
}
if (
nextToolCallIds.size > this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
streamEventCount = nextStreamEventCount
streamEventBytes = nextStreamEventBytes
for (const callId of nextToolCallIds) {
observedToolCallIds.add(callId)
}
const historyTools = extractContinueTools(
state.session.history,
startIndex
)
for (const tool of historyTools) {
observedToolCallIds.add(tool.callId)
}
if (
observedToolCallIds.size > this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
observedTools = mergeContinueTools(
observedTools,
extractContinueTools(state.session.history, startIndex)
historyTools
)
for (const event of state.goodbuddyEvents ?? []) {
for (const event of streamEvents) {
if (event.type === 'text') {
streamedText = true
await runOptions.onEvent?.(event)
@@ -1653,7 +1724,10 @@ export class ContinueHostAdapter {
}
const pending = state.pendingPermission
if (pending && !handledPermissionIds.has(pending.requestId)) {
if (handledPermissionIds.size >= 100) {
if (
handledPermissionIds.size >=
this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
handledPermissionIds.add(pending.requestId)
@@ -1667,9 +1741,14 @@ export class ContinueHostAdapter {
if (
!observedTools.some((tool) => tool.callId === pendingCallId)
) {
if (observedTools.length >= 100) {
if (
!observedToolCallIds.has(pendingCallId) &&
observedToolCallIds.size >=
this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
observedToolCallIds.add(pendingCallId)
observedTools = [
...observedTools,
{
@@ -1771,7 +1850,7 @@ export class ContinueHostAdapter {
signal: cleanupSignal
}).catch(() => undefined)
} finally {
this.terminate(child)
await this.terminate(child)
this.children.delete(child)
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
@@ -1795,31 +1874,24 @@ export class ContinueHostAdapter {
}
}
private terminate(child: ContinueHostChild): void {
if (child.exitCode !== null || child.killed) {
private async terminate(child: ContinueHostChild): Promise<void> {
const existing = this.childTerminations.get(child)
if (existing) {
await existing
return
}
if (process.platform === 'win32' && child.pid) {
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
} else {
child.kill('SIGTERM')
}
const termination = this.dependencies
.terminateProcessTree(child)
.catch(() => undefined)
this.childTerminations.set(child, termination)
await termination
}
dispose(): void {
async dispose(): Promise<void> {
this.pendingQuestions.clear()
for (const child of this.children) {
this.terminate(child)
}
await Promise.all(
[...this.children].map((child) => this.terminate(child))
)
this.children.clear()
}
}
+24
View File
@@ -83,6 +83,7 @@ describe('ContinueAgentRuntime', () => {
text: 'Continue response'
})
mocks.respondHostQuestion.mockResolvedValue(undefined)
mocks.disposeHost.mockResolvedValue(undefined)
})
it('does not launch the CLI for an already-cancelled request', async () => {
@@ -104,6 +105,29 @@ describe('ContinueAgentRuntime', () => {
expect(mocks.runHost).not.toHaveBeenCalled()
})
it('awaits host process cleanup during Runtime disposal', async () => {
let releaseDispose!: () => void
mocks.disposeHost.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseDispose = resolve
})
)
const runtime = createRuntime()
await collectEvents(runtime)
let disposed = false
const disposal = runtime.dispose().then(() => {
disposed = true
})
await Promise.resolve()
expect(disposed).toBe(false)
releaseDispose()
await disposal
expect(disposed).toBe(true)
})
it('uses the resolved binary through the Continue host adapter', async () => {
const runtime = createRuntime()
+3 -3
View File
@@ -784,9 +784,9 @@ export class ContinueAgentRuntime implements AgentRuntime {
async dispose(): Promise<void> {
this.pendingQuestions.clear()
for (const host of this.hostAdapters.values()) {
host.dispose()
}
await Promise.all(
[...this.hostAdapters.values()].map((host) => host.dispose())
)
this.hostAdapters.clear()
}
}
@@ -0,0 +1,88 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import {
createContinueUtilityProcessChild,
type ContinueUtilityProcessSource
} from './continue-utility-process-adapter'
import { waitForProcessExit } from './child-process-termination'
function createSource(): ContinueUtilityProcessSource & EventEmitter {
const emitter =
new EventEmitter() as ContinueUtilityProcessSource & EventEmitter
Object.defineProperties(emitter, {
pid: { value: 42 },
stderr: { value: undefined }
})
emitter.kill = vi.fn(() => true)
emitter.onExit = (listener) => {
emitter.on('exit', listener)
}
emitter.onceExit = (listener) => {
emitter.once('exit', listener)
}
emitter.onceError = (listener) => {
emitter.once('utility-error', listener)
}
emitter.removeExitListener = (listener) => {
emitter.removeListener('exit', listener)
}
emitter.removeErrorListener = (listener) => {
emitter.removeListener('utility-error', listener)
}
return emitter
}
describe('Continue utility process adapter', () => {
it('maps Electron exit to close and completes helper waits immediately', async () => {
const source = createSource()
const child = createContinueUtilityProcessChild(source)
const close = vi.fn()
child.once('close', close)
const waiting = waitForProcessExit(child)
source.emit('exit', 0)
expect(close).toHaveBeenCalledWith(0)
expect(child.exitCode).toBe(0)
await expect(waiting).resolves.toBeUndefined()
})
it('maps utility errors and removes both listener types', () => {
const source = createSource()
const child = createContinueUtilityProcessChild(source)
const close = vi.fn()
const error = vi.fn()
child.once('close', close)
child.once('error', error)
child.removeListener?.('close', close)
child.removeListener?.('error', error)
source.emit('exit', 0)
source.emit('utility-error', 'FatalError', 'worker.js:1', 'report')
expect(close).not.toHaveBeenCalled()
expect(error).not.toHaveBeenCalled()
})
it('converts utility error details to a bounded Error', () => {
const source = createSource()
const child = createContinueUtilityProcessChild(source)
const error = vi.fn()
child.once('error', error)
source.emit(
'utility-error',
'FatalError',
'worker.js:1',
'x'.repeat(1_000)
)
expect(error).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringMatching(
/^Continue 宿主进程异常(worker\.js:1):x{500}$/u
)
})
)
})
})
@@ -0,0 +1,96 @@
import type { ContinueHostChild } from './continue-host-adapter'
type UtilityErrorListener = (
type: 'FatalError',
location: string,
report: string
) => void
export type ContinueUtilityProcessSource = {
readonly pid?: number
readonly stderr?: ContinueHostChild['stderr']
kill(): boolean
onExit(listener: (code: number) => void): void
onceExit(listener: (code: number) => void): void
onceError(listener: UtilityErrorListener): void
removeExitListener(listener: (code: number) => void): void
removeErrorListener(listener: UtilityErrorListener): void
}
export function createContinueUtilityProcessChild(
utility: ContinueUtilityProcessSource
): ContinueHostChild {
let exitCode: number | null = null
let killed = false
const closeListeners = new Map<
(value: Error | number | null) => void,
(code: number) => void
>()
const errorListeners = new Map<
(value: Error | number | null) => void,
UtilityErrorListener
>()
utility.onExit((code) => {
exitCode = code
})
const child: ContinueHostChild = {
get exitCode() {
return exitCode
},
get killed() {
return killed
},
get pid() {
return utility.pid
},
stderr: utility.stderr,
once: (event, listener) => {
if (event === 'close') {
const wrapped = (code: number): void => {
closeListeners.delete(listener)
listener(code)
}
closeListeners.set(listener, wrapped)
utility.onceExit(wrapped)
} else {
const wrapped: UtilityErrorListener = (
_type,
location,
report
): void => {
errorListeners.delete(listener)
listener(
new Error(
`Continue 宿主进程异常(${location}):${report.slice(0, 500)}`
)
)
}
errorListeners.set(listener, wrapped)
utility.onceError(wrapped)
}
return child
},
removeListener: (event, listener) => {
if (event === 'close') {
const wrapped = closeListeners.get(listener)
if (wrapped) {
closeListeners.delete(listener)
utility.removeExitListener(wrapped)
}
} else {
const wrapped = errorListeners.get(listener)
if (wrapped) {
errorListeners.delete(listener)
utility.removeErrorListener(wrapped)
}
}
return child
},
kill: () => {
killed = true
return utility.kill()
}
}
return child
}
+6 -52
View File
@@ -26,6 +26,7 @@ import type {
RuntimeExtensionCatalog,
RuntimeExtensionStoreDependencies
} from './runtime-extension-store'
import { terminateProcessTreeAndWait } from './child-process-termination'
const NPM_REGISTRY_URL = 'https://registry.npmjs.org'
const NPM_SEARCH_PAGE_SIZE = 250
@@ -167,61 +168,14 @@ export type PackageManagerRunner = (
}
) => Promise<PackageManagerRunResult>
function waitForProcessClose(
child: ReturnType<typeof spawn>
): Promise<void> {
if (child.exitCode !== null) {
return Promise.resolve()
}
return new Promise((resolve) => {
const finish = (): void => {
clearTimeout(timer)
child.removeListener('close', finish)
resolve()
}
const timer = setTimeout(finish, 5_000)
child.once('close', finish)
})
}
async function terminatePackageManager(
child: ReturnType<typeof spawn>
): Promise<void> {
const closed = waitForProcessClose(child)
if (process.platform === 'win32' && child.pid) {
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
await new Promise<void>((resolve) => {
const finish = (): void => {
clearTimeout(timer)
killer.removeListener('close', finish)
killer.removeListener('error', finish)
resolve()
}
const timer = setTimeout(finish, 5_000)
killer.once('close', finish)
killer.once('error', finish)
})
} else if (child.pid) {
try {
process.kill(-child.pid, 'SIGKILL')
} catch {
child.kill('SIGKILL')
}
} else {
child.kill('SIGKILL')
}
if (child.exitCode === null) {
child.kill('SIGKILL')
}
await closed
await terminateProcessTreeAndWait(child, {
processGroup: true,
signal: 'SIGKILL',
waitMs: 5_000
})
}
function boundedAppend(current: string, chunk: unknown): string {
+436 -93
View File
@@ -1351,19 +1351,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
expect.any(Array),
expect.any(AbortSignal)
)
expect(setup.client.mcp.add).toHaveBeenCalledWith({
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer custom-capability'
},
oauth: false
}
})
expect(setup.client.mcp.add).toHaveBeenCalledWith(
{
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer custom-capability'
},
oauth: false
}
},
{ signal: expect.any(AbortSignal) }
)
expect(JSON.stringify(
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>)
.mock.calls
@@ -1500,21 +1503,24 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
await expect(stream.next()).resolves.toMatchObject({
value: {
type: 'question',
questionId: 'question-1',
questions: [
{
header: '实现方式',
question: '请选择实现方式',
multiple: false,
custom: true
}
]
}
const questionEvent = await stream.next()
expect(questionEvent.value).toMatchObject({
type: 'question',
questionId: expect.stringMatching(/^opencode-[a-f0-9]{48}$/u),
questions: [
{
header: '实现方式',
question: '请选择实现方式',
multiple: false,
custom: true
}
]
})
await runtime.respondToQuestion('question-1', [['先写测试']])
const questionId =
questionEvent.value?.type === 'question'
? questionEvent.value.questionId
: ''
await runtime.respondToQuestion(questionId, [['先写测试']])
expect(setup.questionReply).toHaveBeenCalledWith({
requestID: 'question-1',
directory: process.cwd(),
@@ -1526,6 +1532,318 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await runtime.dispose()
})
it('namespaces identical upstream question IDs across concurrent external conversations', async () => {
const setup = runClient([])
;(
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
).mockImplementation(async () => ({
stream: (async function* () {
yield {
id: 'question-event',
type: 'question.asked',
properties: {
id: 'shared-question',
sessionID: 'session-1',
questions: [
{
header: 'Choice',
question: 'Choose',
options: [],
multiple: false,
custom: true
}
]
}
}
yield {
id: 'idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
})()
}))
const runtime = new OpenCodeRuntime(
options({
baseUrl: 'http://127.0.0.1:4096',
embedded: false
}),
dependencies(fakeChild(), {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient
}).deps
)
const first = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'first',
workMode: 'execute'
},
new AbortController().signal
)
const second = runtime.run(
{
requestId: '4f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-2',
prompt: 'second',
workMode: 'execute'
},
new AbortController().signal
)
await Promise.all([first.next(), second.next()])
const [firstQuestion, secondQuestion] = await Promise.all([
first.next(),
second.next()
])
const firstId =
firstQuestion.value?.type === 'question'
? firstQuestion.value.questionId
: ''
const secondId =
secondQuestion.value?.type === 'question'
? secondQuestion.value.questionId
: ''
expect(firstId).toMatch(/^opencode-[a-f0-9]{48}$/u)
expect(secondId).toMatch(/^opencode-[a-f0-9]{48}$/u)
expect(firstId).not.toBe(secondId)
await Promise.all([
runtime.respondToQuestion(firstId, [['first answer']]),
runtime.respondToQuestion(secondId, [['second answer']])
])
expect(setup.questionReply).toHaveBeenCalledTimes(2)
expect(setup.questionReply).toHaveBeenNthCalledWith(1, {
requestID: 'shared-question',
directory: process.cwd(),
answers: [['first answer']]
})
expect(setup.questionReply).toHaveBeenNthCalledWith(2, {
requestID: 'shared-question',
directory: process.cwd(),
answers: [['second answer']]
})
await Promise.all([first.next(), second.next()])
await runtime.dispose()
})
it('aborts an OpenCode run at its total execution deadline', async () => {
const setup = runClient([])
;(
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
).mockImplementation(
async (
_input: unknown,
options: { signal: AbortSignal }
) => ({
stream: (async function* () {
await new Promise<void>((_resolve, reject) => {
options.signal.addEventListener(
'abort',
() => reject(options.signal.reason),
{ once: true }
)
})
yield {
id: 'unreachable',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
})()
})
)
const runtime = new OpenCodeRuntime(
options({
baseUrl: 'http://127.0.0.1:4096',
embedded: false
}),
dependencies(fakeChild(), {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient,
executionTimeoutMs: 20
}).deps
)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'never finish',
workMode: 'execute'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
await expect(stream.next()).rejects.toThrow(
'OpenCode 执行超过 20 毫秒总时限'
)
expect(setup.session.abort).toHaveBeenCalled()
await runtime.dispose()
})
it('applies the total deadline while agent discovery is stalled', async () => {
const setup = runClient([])
const agents = vi.fn(
() => new Promise<never>(() => undefined)
)
Object.assign(setup.client, {
app: { agents }
})
const child = fakeChild()
const runtime = new OpenCodeRuntime(
options({
customization: { defaultAgent: 'build' }
}),
dependencies(child, {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient,
executionTimeoutMs: 20
}).deps
)
setTimeout(() => {
stdoutOf(child).write(
'opencode server listening on http://127.0.0.1:4010\n'
)
}, 0)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'never reach the stream',
workMode: 'execute'
},
new AbortController().signal
)
await expect(stream.next()).rejects.toThrow(
'OpenCode 执行超过 20 毫秒总时限'
)
expect(agents).toHaveBeenCalledWith(
{ directory: process.cwd() },
{ signal: expect.any(AbortSignal) }
)
expect(setup.event.subscribe).not.toHaveBeenCalled()
await runtime.dispose()
})
it('deletes a session created after timeout and does not reuse it', async () => {
const setup = runClient([
{
id: 'idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
let resolveStalledCreation!: (value: {
data: { id: string }
error: undefined
}) => void
;(
setup.session.create as unknown as ReturnType<typeof vi.fn>
).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveStalledCreation = resolve
})
)
const runtime = new OpenCodeRuntime(
options({
baseUrl: 'http://127.0.0.1:4096',
embedded: false
}),
dependencies(fakeChild(), {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient,
executionTimeoutMs: 20
}).deps
)
const first = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'stalled creation',
workMode: 'execute'
},
new AbortController().signal
)
await expect(first.next()).rejects.toThrow(
'OpenCode 执行超过 20 毫秒总时限'
)
resolveStalledCreation({
data: { id: 'stale-session' },
error: undefined
})
await vi.waitFor(() =>
expect(setup.session.delete).toHaveBeenCalledWith(
{
sessionID: 'stale-session',
directory: process.cwd()
},
{ signal: expect.any(AbortSignal) }
)
)
const secondEvents = await collectRun(runtime)
expect(secondEvents.at(-1)).toMatchObject({ type: 'done' })
expect(setup.session.create).toHaveBeenCalledTimes(2)
expect(setup.session.update).not.toHaveBeenCalled()
await runtime.dispose()
})
it('fails before emitting text that exceeds the aggregate output budget', async () => {
const setup = runClient([
{
id: 'first-text',
type: 'message.part.delta',
properties: {
sessionID: 'session-1',
partID: 'part-1',
field: 'text',
delta: 'a'.repeat(600_000)
}
},
{
id: 'second-text',
type: 'message.part.delta',
properties: {
sessionID: 'session-1',
partID: 'part-1',
field: 'text',
delta: 'b'.repeat(600_000)
}
}
])
const runtime = embeddedRuntime(setup.client)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'test',
workMode: 'execute'
},
new AbortController().signal
)
await stream.next()
const firstText = await stream.next()
expect(firstText.value).toMatchObject({
type: 'text',
delta: expect.stringMatching(/^a+$/u)
})
await expect(stream.next()).rejects.toThrow(
'文本与推理输出超过 1 MB 安全限制'
)
expect(setup.session.abort).toHaveBeenCalled()
await runtime.dispose()
})
it('adds only request-scoped built-in read tools for Ask and disconnects them', async () => {
const setup = runClient([
{
@@ -1592,19 +1910,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
events.push(event)
}
expect(setup.client.mcp.add).toHaveBeenCalledWith({
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer secret-capability'
},
oauth: false
}
})
expect(setup.client.mcp.add).toHaveBeenCalledWith(
{
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer secret-capability'
},
oauth: false
}
},
{ signal: expect.any(AbortSignal) }
)
const knowledgeMcpName = (
(
setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>
@@ -1621,7 +1942,8 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
action: 'allow'
}
]
})
}),
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1634,10 +1956,13 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
}),
expect.anything()
)
expect(setup.client.mcp.disconnect).toHaveBeenCalledWith({
name: expect.stringMatching(/^goodbuddy-data-/u),
directory: process.cwd()
})
expect(setup.client.mcp.disconnect).toHaveBeenCalledWith(
{
name: expect.stringMatching(/^goodbuddy-data-/u),
directory: process.cwd()
},
{ signal: expect.any(AbortSignal) }
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
await runtime.dispose()
})
@@ -1911,19 +2236,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
try {
await collectRun(runtime, 'ask')
expect(setup.session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' },
{ permission: 'skill', pattern: '*', action: 'deny' },
{
permission: 'skill',
pattern: 'longdoc-docx',
action: 'allow'
}
]
})
expect(setup.session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' },
{ permission: 'skill', pattern: '*', action: 'deny' },
{
permission: 'skill',
pattern: 'longdoc-docx',
action: 'allow'
}
]
},
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
system: undefined,
@@ -2001,13 +2329,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
const events = await collectRun(runtime, 'execute')
expect(callOrder).toEqual(['subscribe', 'prompt'])
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
})
expect(session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
},
{ signal: expect.any(AbortSignal) }
)
expect(permissionReply).toHaveBeenCalledOnce()
expect(permissionReply).toHaveBeenCalledWith({
requestID: 'permission-1',
@@ -2341,16 +2672,20 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'ask')
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
})
expect(tool.ids).toHaveBeenCalledWith({
directory: process.cwd()
})
expect(session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
},
{ signal: expect.any(AbortSignal) }
)
expect(tool.ids).toHaveBeenCalledWith(
{ directory: process.cwd() },
{ signal: expect.any(AbortSignal) }
)
expect(session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
tools: {
@@ -2378,13 +2713,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'execute')
await collectRun(runtime, 'ask')
expect(session.update).toHaveBeenCalledWith({
sessionID: 'session-1',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
})
expect(session.update).toHaveBeenCalledWith(
{
sessionID: 'session-1',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
},
{ signal: expect.any(AbortSignal) }
)
await runtime.dispose()
})
@@ -2411,13 +2749,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'execute')
expect(runtime.requiresToolApproval).toBe(false)
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
})
expect(session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
},
{ signal: expect.any(AbortSignal) }
)
expect(permissionReply).not.toHaveBeenCalled()
await runtime.dispose()
})
@@ -2814,7 +3155,8 @@ describe('OpenCodeRuntime native customization', () => {
}
expect(setup.session.create).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'plan' })
expect.objectContaining({ agent: 'plan' }),
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'plan' }),
@@ -2853,7 +3195,8 @@ describe('OpenCodeRuntime native customization', () => {
await collectRun(runtime)
expect(setup.session.create).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'build' })
expect.objectContaining({ agent: 'build' }),
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'build' }),
@@ -3099,7 +3442,7 @@ describe('OpenCodeRuntime native customization', () => {
})
expect(context).toHaveBeenCalledWith(
{ sessionID: 'session-1' },
{ signal }
{ signal: expect.any(AbortSignal) }
)
expect(summarize).toHaveBeenCalledWith(
{
@@ -3109,7 +3452,7 @@ describe('OpenCodeRuntime native customization', () => {
modelID: 'claude-sonnet',
auto: false
},
{ signal }
{ signal: expect.any(AbortSignal) }
)
await runtime.dispose()
})
+291 -114
View File
@@ -56,6 +56,10 @@ import type {
RuntimeSkillPackage
} from '../capabilities/capability-service'
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
import {
requestProcessTreeTermination,
waitForProcessExit
} from './child-process-termination'
const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024
const STARTUP_TIMEOUT_MS = 10_000
@@ -65,6 +69,8 @@ const MAX_PERMISSION_PATTERN_LENGTH = 1_024
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024
const MAX_TOOL_CALLS_PER_RUN = 100
const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024
const MAX_EXECUTION_MILLISECONDS = 10 * 60_000
const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024
const MAX_QUESTIONS_PER_REQUEST = 4
const MAX_QUESTION_OPTIONS = 20
@@ -243,6 +249,44 @@ function byteLengthWithin(value: string, maximum: number): boolean {
return Buffer.byteLength(value) <= maximum
}
function createPublicQuestionId(
requestId: string,
sessionId: string,
upstreamQuestionId: string
): string {
return `opencode-${createHash('sha256')
.update(`${requestId}\0${sessionId}\0${upstreamQuestionId}`)
.digest('hex')
.slice(0, 48)}`
}
function executionDeadlineLabel(milliseconds: number): string {
return milliseconds % 60_000 === 0
? `${milliseconds / 60_000} 分钟`
: `${milliseconds} 毫秒`
}
function awaitWithAbort<T>(
operation: Promise<T>,
signal: AbortSignal
): Promise<T> {
signal.throwIfAborted()
return new Promise<T>((resolveOperation, rejectOperation) => {
const abort = (): void => rejectOperation(signal.reason)
signal.addEventListener('abort', abort, { once: true })
operation.then(
(value) => {
signal.removeEventListener('abort', abort)
resolveOperation(value)
},
(error: unknown) => {
signal.removeEventListener('abort', abort)
rejectOperation(error)
}
)
})
}
function areBoundedPatterns(value: unknown): value is string[] {
return (
Array.isArray(value) &&
@@ -416,6 +460,7 @@ export type OpenCodeRuntimeDependencies = {
createClient: typeof createOpencodeClient
platform: NodeJS.Platform
startupTimeoutMs: number
executionTimeoutMs: number
}
export type OpenCodeRuntimeOptions = {
@@ -678,6 +723,7 @@ export class OpenCodeRuntime implements AgentRuntime {
client: OpencodeClient
directory: string
questionCount: number
upstreamQuestionId: string
}
>()
private embeddedRunTail: Promise<void> = Promise.resolve()
@@ -694,6 +740,7 @@ export class OpenCodeRuntime implements AgentRuntime {
createClient: createOpencodeClient,
platform: process.platform,
startupTimeoutMs: STARTUP_TIMEOUT_MS,
executionTimeoutMs: MAX_EXECUTION_MILLISECONDS,
...dependencies
}
}
@@ -782,36 +829,14 @@ export class OpenCodeRuntime implements AgentRuntime {
}
private terminate(child: SpawnedProcess): void {
if (child.exitCode !== null) {
return
}
if (this.dependencies.platform === 'win32' && child.pid) {
const killer = this.dependencies.spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
} else {
child.kill('SIGTERM')
}
requestProcessTreeTermination(child, {
platform: this.dependencies.platform,
spawn: this.dependencies.spawn
})
}
private waitForExit(child: SpawnedProcess): Promise<void> {
if (child.exitCode !== null) {
return Promise.resolve()
}
return new Promise((resolveExit) => {
const timeout = setTimeout(resolveExit, 2_000)
child.once('close', () => {
clearTimeout(timeout)
resolveExit()
})
})
return waitForProcessExit(child)
}
private getNativeSkillIds(): string[] {
@@ -1083,9 +1108,12 @@ export class OpenCodeRuntime implements AgentRuntime {
if (this.client) {
return this.client
}
const existingInitialization = this.clientInitialization
this.clientInitialization ??= this.initializeClient(signal)
try {
return await this.clientInitialization
return signal && existingInitialization
? await awaitWithAbort(this.clientInitialization, signal)
: await this.clientInitialization
} catch (error) {
this.clientInitialization = undefined
throw error
@@ -1154,7 +1182,8 @@ export class OpenCodeRuntime implements AgentRuntime {
}
private async discoverAgents(
client: OpencodeClient
client: OpencodeClient,
signal?: AbortSignal
): Promise<
Array<{
id: string
@@ -1165,9 +1194,15 @@ export class OpenCodeRuntime implements AgentRuntime {
hidden: boolean
}>
> {
const response = await client.app.agents({
directory: this.options.defaultWorkspace
})
const operation = client.app.agents(
{
directory: this.options.defaultWorkspace
},
signal ? { signal } : undefined
)
const response = signal
? await awaitWithAbort(operation, signal)
: await operation
if (response.error || !response.data) {
throw new Error('OpenCode Agent 清单不可用')
}
@@ -1197,7 +1232,8 @@ export class OpenCodeRuntime implements AgentRuntime {
private async resolveSelectedAgent(
client: OpencodeClient,
request: AgentExecutionRequest
request: AgentExecutionRequest,
signal: AbortSignal
): Promise<string | undefined> {
const control =
request.runtimeControl?.provider === 'opencode'
@@ -1213,7 +1249,7 @@ export class OpenCodeRuntime implements AgentRuntime {
'外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent'
)
}
const agents = await this.discoverAgents(client)
const agents = await this.discoverAgents(client, signal)
if (
!agents.some(
(agent) =>
@@ -1592,6 +1628,7 @@ export class OpenCodeRuntime implements AgentRuntime {
client: OpencodeClient,
request: AgentExecutionRequest,
directory: string,
signal: AbortSignal,
agent?: string,
permission?: PermissionRuleset
): Promise<{ id: string; created: boolean }> {
@@ -1603,27 +1640,62 @@ export class OpenCodeRuntime implements AgentRuntime {
request.conversationId
)
if (pending) {
return { id: await pending, created: false }
return {
id: await awaitWithAbort(pending, signal),
created: false
}
}
const creation = client.session
.create({
title: 'GoodBuddy 对话',
directory,
...(agent ? { agent } : {}),
...(permission ? { permission } : {})
})
const creation: Promise<string> = client.session
.create(
{
title: 'GoodBuddy 对话',
directory,
...(agent ? { agent } : {}),
...(permission ? { permission } : {})
},
{ signal }
)
.then((response) => {
if (!response.data) {
throw new Error('OpenCode 会话创建失败')
}
this.sessions.set(request.conversationId, response.data.id)
return response.data.id
const sessionId = response.data.id
const stillCurrent =
this.sessionInitializations.get(request.conversationId) ===
creation
if (signal.aborted || !stillCurrent) {
if (this.sessions.get(request.conversationId) !== sessionId) {
void client.session
.delete(
{
sessionID: sessionId,
directory
},
{ signal: AbortSignal.timeout(1_000) }
)
.catch(() => undefined)
}
if (signal.aborted) {
throw signal.reason
}
throw new Error('OpenCode 会话初始化已失效')
}
this.sessions.set(request.conversationId, sessionId)
return sessionId
})
this.sessionInitializations.set(request.conversationId, creation)
try {
return { id: await creation, created: true }
return {
id: await awaitWithAbort(creation, signal),
created: true
}
} finally {
this.sessionInitializations.delete(request.conversationId)
if (
this.sessionInitializations.get(request.conversationId) ===
creation
) {
this.sessionInitializations.delete(request.conversationId)
}
}
}
@@ -1631,17 +1703,42 @@ export class OpenCodeRuntime implements AgentRuntime {
request: AgentExecutionRequest,
signal: AbortSignal
): AsyncGenerator<RuntimeEvent, void, void> {
const releaseEmbedded = this.usesEmbeddedPermissionMediation()
? await this.acquireEmbeddedRun(signal)
: undefined
const releaseConversation = await this.acquireConversationRun(
request.conversationId,
signal
const deadline = new AbortController()
const deadlineTimer = setTimeout(
() =>
deadline.abort(
new Error(
`OpenCode 执行超过 ${executionDeadlineLabel(
this.dependencies.executionTimeoutMs
)}总时限`
)
),
this.dependencies.executionTimeoutMs
)
deadlineTimer.unref?.()
const executionSignal = AbortSignal.any([
signal,
deadline.signal
])
let releaseEmbedded: (() => void) | undefined
let releaseConversation: (() => void) | undefined
try {
yield* this.runUnlocked(request, signal)
releaseEmbedded = this.usesEmbeddedPermissionMediation()
? await this.acquireEmbeddedRun(executionSignal)
: undefined
releaseConversation = await this.acquireConversationRun(
request.conversationId,
executionSignal
)
yield* this.runUnlocked(request, executionSignal)
} catch (error) {
if (deadline.signal.aborted && !signal.aborted) {
throw deadline.signal.reason
}
throw error
} finally {
releaseConversation()
clearTimeout(deadlineTimer)
releaseConversation?.()
releaseEmbedded?.()
}
}
@@ -1674,7 +1771,8 @@ export class OpenCodeRuntime implements AgentRuntime {
}
const selectedAgent = await this.resolveSelectedAgent(
client,
request
request,
signal
)
let selectedCommand:
| {
@@ -1683,7 +1781,10 @@ export class OpenCodeRuntime implements AgentRuntime {
}
| undefined
if (runtimeControl?.command) {
const commandResponse = await client.command.list({ directory })
const commandResponse = await awaitWithAbort(
client.command.list({ directory }, { signal }),
signal
)
if (commandResponse.error || !commandResponse.data) {
throw new Error('OpenCode 无法验证原生命令')
}
@@ -1725,19 +1826,25 @@ export class OpenCodeRuntime implements AgentRuntime {
.update(`${request.conversationId}\0${request.requestId}`)
.digest('hex')
.slice(0, 20)}`
const added = await client.mcp.add({
directory,
name: knowledgeMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${request.knowledgeCapabilityToken}`
const added = await awaitWithAbort(
client.mcp.add(
{
directory,
name: knowledgeMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${request.knowledgeCapabilityToken}`
},
oauth: false
}
},
oauth: false
}
})
{ signal }
),
signal
)
if (added.error || !added.data) {
throw new Error('OpenCode 内置只读工具连接失败')
}
@@ -1776,19 +1883,25 @@ export class OpenCodeRuntime implements AgentRuntime {
.update(`${request.conversationId}\0${request.requestId}`)
.digest('hex')
.slice(0, 20)}`
const added = await client.mcp.add({
directory,
name: customMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${customMcpToken}`
const added = await awaitWithAbort(
client.mcp.add(
{
directory,
name: customMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${customMcpToken}`
},
oauth: false
}
},
oauth: false
}
})
{ signal }
),
signal
)
const addedStatus = added.data?.[customMcpName]
if (
added.error ||
@@ -1834,9 +1947,10 @@ export class OpenCodeRuntime implements AgentRuntime {
]
let disabledTools: Record<string, boolean> | undefined
if (request.workMode !== 'execute') {
const tools = await client.tool.ids({
directory
})
const tools = await awaitWithAbort(
client.tool.ids({ directory }, { signal }),
signal
)
if (tools.error || !tools.data) {
throw new Error('OpenCode 无法确认工具已禁用,已阻止只读请求')
}
@@ -1854,16 +1968,23 @@ export class OpenCodeRuntime implements AgentRuntime {
client,
request,
directory,
signal,
selectedAgent,
permission
)
const sessionId = session.id
if (!session.created) {
const update = await client.session.update({
sessionID: sessionId,
directory,
permission
})
const update = await awaitWithAbort(
client.session.update(
{
sessionID: sessionId,
directory,
permission
},
{ signal }
),
signal
)
if (update.error || !update.data) {
throw new Error('OpenCode 会话权限配置失败')
}
@@ -1875,9 +1996,10 @@ export class OpenCodeRuntime implements AgentRuntime {
message: 'OpenCode 正在处理请求'
}
const subscription = await client.event.subscribe({
directory
}, { signal })
const subscription = await awaitWithAbort(
client.event.subscribe({ directory }, { signal }),
signal
)
const abortSession = (): void => {
void client.session.abort({
@@ -1898,7 +2020,16 @@ export class OpenCodeRuntime implements AgentRuntime {
}
>()
const reasoningPartIds = new Set<string>()
const reportedQuestionIds = new Set<string>()
const reportedQuestionIds = new Map<string, string>()
let aggregateOutputBytes = 0
const consumeOutputBudget = (delta: string): void => {
aggregateOutputBytes += Buffer.byteLength(delta)
if (aggregateOutputBytes > MAX_EXECUTION_OUTPUT_BYTES) {
throw new Error(
'OpenCode 单次运行的文本与推理输出超过 1 MB 安全限制'
)
}
}
let hasResponseTextAfterFailure = false
try {
const promptText =
@@ -1998,6 +2129,7 @@ export class OpenCodeRuntime implements AgentRuntime {
'thinking'
].includes(event.properties.field)
if (reasoning || event.properties.field === 'text') {
consumeOutputBudget(event.properties.delta)
if (
!reasoning &&
/\S/u.test(event.properties.delta) &&
@@ -2077,6 +2209,7 @@ export class OpenCodeRuntime implements AgentRuntime {
event.properties.sessionID === sessionId &&
event.properties.delta
) {
consumeOutputBudget(event.properties.delta)
yield {
requestId: request.requestId,
type: 'reasoning',
@@ -2096,16 +2229,28 @@ export class OpenCodeRuntime implements AgentRuntime {
questionRequest &&
!reportedQuestionIds.has(questionRequest.id)
) {
reportedQuestionIds.add(questionRequest.id)
this.pendingQuestions.set(questionRequest.id, {
const publicQuestionId = createPublicQuestionId(
request.requestId,
sessionId,
questionRequest.id
)
if (this.pendingQuestions.has(publicQuestionId)) {
throw new Error('OpenCode 提问公开 ID 与另一活动请求冲突')
}
reportedQuestionIds.set(
questionRequest.id,
publicQuestionId
)
this.pendingQuestions.set(publicQuestionId, {
client,
directory,
questionCount: questionRequest.questions.length
questionCount: questionRequest.questions.length,
upstreamQuestionId: questionRequest.id
})
yield {
requestId: request.requestId,
type: 'question',
questionId: questionRequest.id,
questionId: publicQuestionId,
questions: questionRequest.questions.map((question) => ({
header: question.header,
question: question.question,
@@ -2125,7 +2270,12 @@ export class OpenCodeRuntime implements AgentRuntime {
event.type === 'question.rejected') &&
event.properties.sessionID === sessionId
) {
this.pendingQuestions.delete(event.properties.requestID)
const publicQuestionId = reportedQuestionIds.get(
event.properties.requestID
)
if (publicQuestionId) {
this.pendingQuestions.delete(publicQuestionId)
}
}
if (
@@ -2320,20 +2470,29 @@ export class OpenCodeRuntime implements AgentRuntime {
throw error
} finally {
signal.removeEventListener('abort', abortSession)
for (const questionId of reportedQuestionIds) {
for (const questionId of reportedQuestionIds.values()) {
this.pendingQuestions.delete(questionId)
}
}
} finally {
const cleanupSignal = AbortSignal.timeout(1_000)
if (knowledgeMcpName) {
await client.mcp
.disconnect({ name: knowledgeMcpName, directory })
.catch(() => undefined)
await awaitWithAbort(
client.mcp.disconnect(
{ name: knowledgeMcpName, directory },
{ signal: cleanupSignal }
),
cleanupSignal
).catch(() => undefined)
}
if (customMcpName) {
await client.mcp
.disconnect({ name: customMcpName, directory })
.catch(() => undefined)
await awaitWithAbort(
client.mcp.disconnect(
{ name: customMcpName, directory },
{ signal: cleanupSignal }
),
cleanupSignal
).catch(() => undefined)
}
if (customMcpToken) {
this.options.knowledgeGateway?.revoke(customMcpToken)
@@ -2352,13 +2511,13 @@ export class OpenCodeRuntime implements AgentRuntime {
const response = answers
? answers.length === pending.questionCount
? await pending.client.question.reply({
requestID: questionId,
requestID: pending.upstreamQuestionId,
directory: pending.directory,
answers
})
: undefined
: await pending.client.question.reject({
requestID: questionId,
requestID: pending.upstreamQuestionId,
directory: pending.directory
})
if (!response) {
@@ -2382,12 +2541,24 @@ export class OpenCodeRuntime implements AgentRuntime {
'外部 OpenCode Server 不支持由 GoodBuddy 执行原生 Compact'
)
}
const releaseEmbedded = await this.acquireEmbeddedRun(signal)
const deadline = new AbortController()
const deadlineTimer = setTimeout(
() =>
deadline.abort(new Error('OpenCode 原生 Compact 执行超时')),
this.dependencies.executionTimeoutMs
)
deadlineTimer.unref?.()
const executionSignal = AbortSignal.any([
signal,
deadline.signal
])
let releaseEmbedded: (() => void) | undefined
let releaseConversation: (() => void) | undefined
try {
releaseEmbedded = await this.acquireEmbeddedRun(executionSignal)
releaseConversation = await this.acquireConversationRun(
request.conversationId,
signal
executionSignal
)
const sessionId = this.sessions.get(request.conversationId)
if (!sessionId) {
@@ -2400,10 +2571,10 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
}
const client = await this.getClient(signal)
const client = await this.getClient(executionSignal)
const context = await client.v2.session.context(
{ sessionID: sessionId },
{ signal }
{ signal: executionSignal }
)
if (context.error || !context.data) {
throw new Error('OpenCode 原生上下文不可用,无法执行 Compact')
@@ -2434,13 +2605,13 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
}
signal.throwIfAborted()
executionSignal.throwIfAborted()
const subscriptionController = new AbortController()
const subscription = await client.event.subscribe(
{ directory: this.options.defaultWorkspace },
{
signal: AbortSignal.any([
signal,
executionSignal,
subscriptionController.signal
])
}
@@ -2482,7 +2653,7 @@ export class OpenCodeRuntime implements AgentRuntime {
modelID: configuredModel.modelID,
auto: false
},
{ signal }
{ signal: executionSignal }
)
if (compact.error || compact.data !== true) {
throw new Error(
@@ -2522,9 +2693,15 @@ export class OpenCodeRuntime implements AgentRuntime {
subscriptionController.abort()
await usageCapture.catch(() => undefined)
}
} catch (error) {
if (deadline.signal.aborted && !signal.aborted) {
throw deadline.signal.reason
}
throw error
} finally {
clearTimeout(deadlineTimer)
releaseConversation?.()
releaseEmbedded()
releaseEmbedded?.()
}
}
+53 -2
View File
@@ -1,10 +1,13 @@
import { EventEmitter } from 'node:events'
import { realpath } from 'node:fs/promises'
import { basename, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
detectAgentRuntimes,
detectRuntimeBinary
detectRuntimeBinary,
validateRuntimeVersion,
type RuntimeVersionProcess
} from './runtime-discovery'
const originalPath = process.env.PATH
@@ -24,6 +27,54 @@ afterEach(() => {
})
describe('runtime discovery', () => {
it('launches a detached POSIX version probe and awaits bounded tree cleanup', async () => {
const child =
new EventEmitter() as RuntimeVersionProcess & EventEmitter
child.exitCode = null
child.pid = 2718
child.kill = vi.fn()
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
const spawnProcess = vi.fn(() => child)
let releaseCleanup!: () => void
const cleanupBlocked = new Promise<void>((resolve) => {
releaseCleanup = resolve
})
const terminateProcessTree = vi.fn(async () => {
await cleanupBlocked
})
const validation = validateRuntimeVersion('runtime', {
platform: 'linux',
spawnProcess,
terminateProcessTree,
outputLimit: 4,
terminationWaitMs: 25
})
;(child.stdout as EventEmitter).emit('data', '12345')
let completed = false
void validation.then(() => {
completed = true
})
await Promise.resolve()
expect(completed).toBe(false)
expect(spawnProcess).toHaveBeenCalledWith(
'runtime',
['--version'],
expect.objectContaining({ detached: true })
)
expect(terminateProcessTree).toHaveBeenCalledWith(child, {
platform: 'linux',
processGroup: true,
signal: 'SIGKILL',
waitMs: 25
})
releaseCleanup()
await expect(validation).resolves.toEqual({ valid: false })
})
it('canonicalizes and validates a configured ordinary file first', async () => {
process.env.PATH = ''
process.env.Path = ''
+103 -44
View File
@@ -8,6 +8,10 @@ import {
normalize
} from 'node:path'
import spawn from 'cross-spawn'
import {
terminateProcessTreeAndWait,
type WaitableProcessTreeChild
} from './child-process-termination'
import { buildRuntimeEnvironment } from './process-environment'
import type {
AgentRuntimeDetection,
@@ -16,6 +20,7 @@ import type {
const VERSION_TIMEOUT_MS = 3_000
const VERSION_OUTPUT_LIMIT = 8 * 1024
const VERSION_TERMINATION_WAIT_MS = 500
export type RuntimeBinaryDiscoveryInput = {
binaryPath: string
@@ -31,6 +36,39 @@ type VersionValidation =
| { valid: true; version?: string }
| { valid: false }
type RuntimeVersionOutput = {
on(
event: 'data',
listener: (chunk: Buffer | string) => void
): unknown
}
export type RuntimeVersionProcess = WaitableProcessTreeChild & {
stdout?: RuntimeVersionOutput | null
stderr?: RuntimeVersionOutput | null
}
export type RuntimeVersionSpawn = (
command: string,
args: string[],
options: {
detached: boolean
env: NodeJS.ProcessEnv
shell: false
stdio: ['ignore', 'pipe', 'pipe']
windowsHide: true
}
) => RuntimeVersionProcess
export type RuntimeVersionValidationDependencies = {
platform?: NodeJS.Platform
spawnProcess?: RuntimeVersionSpawn
terminateProcessTree?: typeof terminateProcessTreeAndWait
timeoutMs?: number
outputLimit?: number
terminationWaitMs?: number
}
function stripUnsafeCharacters(value: string): string {
let result = ''
let inEscapeSequence = false
@@ -66,45 +104,36 @@ function safeVersion(output: string): string | undefined {
return (semanticVersion?.[1] ?? firstLine).slice(0, 160)
}
function terminate(child: ReturnType<typeof spawn>): void {
if (child.exitCode !== null || child.killed) {
return
}
if (process.platform === 'win32' && child.pid) {
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
return
}
child.kill('SIGKILL')
}
function validateVersion(binaryPath: string): Promise<VersionValidation> {
export function validateRuntimeVersion(
binaryPath: string,
dependencies: RuntimeVersionValidationDependencies = {}
): Promise<VersionValidation> {
return new Promise((resolve) => {
let settled = false
let cleanupStarted = false
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let stderrBytes = 0
const platform = dependencies.platform ?? process.platform
const timeoutMs = dependencies.timeoutMs ?? VERSION_TIMEOUT_MS
const outputLimit =
dependencies.outputLimit ?? VERSION_OUTPUT_LIMIT
const child = spawn(binaryPath, ['--version'], {
env: buildRuntimeEnvironment({}),
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
const child = (dependencies.spawnProcess ?? spawn)(
binaryPath,
['--version'],
{
detached: platform !== 'win32',
env: buildRuntimeEnvironment({}),
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
)
const finish = (result: VersionValidation): void => {
if (settled) {
if (settled || cleanupStarted) {
return
}
settled = true
@@ -112,21 +141,43 @@ function validateVersion(binaryPath: string): Promise<VersionValidation> {
resolve(result)
}
const exceedLimit = (): void => {
terminate(child)
finish({ valid: false })
const failAfterCleanup = (): void => {
if (settled || cleanupStarted) {
return
}
cleanupStarted = true
clearTimeout(timeout)
void (
dependencies.terminateProcessTree ??
terminateProcessTreeAndWait
)(child, {
platform,
processGroup: platform !== 'win32',
signal: 'SIGKILL',
waitMs:
dependencies.terminationWaitMs ??
VERSION_TERMINATION_WAIT_MS
}).then(
() => {
settled = true
resolve({ valid: false })
},
() => {
settled = true
resolve({ valid: false })
}
)
}
const timeout = setTimeout(() => {
terminate(child)
finish({ valid: false })
}, VERSION_TIMEOUT_MS)
failAfterCleanup()
}, timeoutMs)
child.stdout?.on('data', (chunk: Buffer | string) => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
stdoutBytes += value.byteLength
if (stdoutBytes > VERSION_OUTPUT_LIMIT) {
exceedLimit()
if (stdoutBytes > outputLimit) {
failAfterCleanup()
return
}
stdout += value.toString('utf8')
@@ -134,14 +185,22 @@ function validateVersion(binaryPath: string): Promise<VersionValidation> {
child.stderr?.on('data', (chunk: Buffer | string) => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
stderrBytes += value.byteLength
if (stderrBytes > VERSION_OUTPUT_LIMIT) {
exceedLimit()
if (stderrBytes > outputLimit) {
failAfterCleanup()
return
}
stderr += value.toString('utf8')
})
child.once('error', () => finish({ valid: false }))
child.once('error', () => {
if (cleanupStarted) {
return
}
finish({ valid: false })
})
child.once('close', (code) => {
if (cleanupStarted) {
return
}
if (code !== 0) {
finish({ valid: false })
return
@@ -286,7 +345,7 @@ export async function detectRuntimeBinary(
'bundled'
)
}
const validation = await validateVersion(canonicalPath)
const validation = await validateRuntimeVersion(canonicalPath)
return validation.valid
? availableDetection(
input.label,
@@ -305,7 +364,7 @@ export async function detectRuntimeBinary(
if (!canonicalPath) {
configuredPathProblem = 'invalid'
} else {
const validation = await validateVersion(canonicalPath)
const validation = await validateRuntimeVersion(canonicalPath)
if (validation.valid) {
return availableDetection(
input.label,
@@ -332,7 +391,7 @@ export async function detectRuntimeBinary(
continue
}
foundAutomaticCandidate = true
const validation = await validateVersion(canonicalPath)
const validation = await validateRuntimeVersion(canonicalPath)
if (validation.valid) {
return availableDetection(
input.label,
@@ -3,7 +3,9 @@ import {
mkdtemp,
readFile,
readdir,
rename,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -302,6 +304,177 @@ describe('RuntimeExtensionStore', () => {
})
})
it('rolls back an interrupted upgrade whose state was not committed', async () => {
const first = catalogEntry('1.0.0')
const second = catalogEntry('2.0.0')
const { userDataPath, store, dependencies } = await fixture({
entries: [first],
temporaryIds: ['install-one']
})
await store.apply({
type: 'install',
extensionId: first.id,
package: first.package
})
const root = join(userDataPath, 'runtime-extensions')
const statePath = join(root, 'store.json')
const before = JSON.parse(await readFile(statePath, 'utf8')) as {
version: 2
marketplaceEnabled: boolean
installed: Array<Record<string, unknown>>
}
const after = structuredClone(before)
after.installed[0] = {
...after.installed[0],
package: second.package,
installedAt: '2026-08-17T00:00:00.000Z',
integrity: `sha512-${Buffer.from('new').toString('base64')}`
}
const extensionDirectory = join(
root,
'extensions',
first.id
)
const backupDirectory = join(
root,
'.staging',
'upgrade-crash-previous'
)
await rename(extensionDirectory, backupDirectory)
await mkdir(join(extensionDirectory, 'dist'), { recursive: true })
await writeFile(
join(extensionDirectory, 'dist', 'index.js'),
'export default "new"'
)
await writeFile(
join(root, '.mutation-journal.json'),
JSON.stringify({
version: 1,
kind: 'install',
extensionId: first.id,
temporaryId: 'upgrade-crash',
before,
after
})
)
const recovered = new RuntimeExtensionStore(
userDataPath,
dependencies
)
await expect(recovered.getSnapshot()).resolves.toMatchObject({
installed: [
expect.objectContaining({ package: first.package })
]
})
await expect(
readFile(join(extensionDirectory, 'dist', 'index.js'), 'utf8')
).resolves.toBe('export default {}')
await expect(readdir(join(root, '.staging'))).resolves.toEqual([])
await expect(readdir(root)).resolves.not.toContain(
'.mutation-journal.json'
)
})
it('finishes an interrupted committed removal on initialization', async () => {
const { userDataPath, store, dependencies } = await fixture({
temporaryIds: ['install-one']
})
const entry = catalogEntry()
await store.apply({
type: 'install',
extensionId: entry.id,
package: entry.package
})
const root = join(userDataPath, 'runtime-extensions')
const statePath = join(root, 'store.json')
const before = JSON.parse(await readFile(statePath, 'utf8')) as {
version: 2
marketplaceEnabled: boolean
installed: Array<Record<string, unknown>>
}
const after = { ...before, installed: [] }
const trashDirectory = join(
root,
'.staging',
'remove-crash-removed'
)
await rename(
join(root, 'extensions', entry.id),
trashDirectory
)
await writeFile(statePath, JSON.stringify(after))
await writeFile(
join(root, '.mutation-journal.json'),
JSON.stringify({
version: 1,
kind: 'remove',
extensionId: entry.id,
temporaryId: 'remove-crash',
before,
after
})
)
const recovered = new RuntimeExtensionStore(
userDataPath,
dependencies
)
await expect(recovered.getSnapshot()).resolves.toMatchObject({
installed: []
})
await expect(readdir(join(root, '.staging'))).resolves.toEqual([])
await expect(readdir(join(root, 'extensions'))).resolves.toEqual([])
await expect(readdir(root)).resolves.not.toContain(
'.mutation-journal.json'
)
})
it('cleans only safe unjournaled managed staging directories', async () => {
const { userDataPath, dependencies } = await fixture({
marketplaceEnabled: false
})
const root = join(userDataPath, 'runtime-extensions')
const staging = join(root, '.staging')
const abandoned =
'00000000-0000-4000-8000-000000000101'
const abandonedBackup =
'00000000-0000-4000-8000-000000000102-previous'
const unrelated = 'user-staging-backup'
const outside = join(userDataPath, 'outside-staging')
const linked =
'00000000-0000-4000-8000-000000000103-removed'
await mkdir(staging, { recursive: true })
await Promise.all([
mkdir(join(staging, abandoned)),
mkdir(join(staging, abandonedBackup)),
mkdir(join(staging, unrelated)),
mkdir(outside)
])
await writeFile(join(staging, abandoned, 'partial.js'), 'stale')
await writeFile(join(staging, unrelated, 'keep.txt'), 'keep')
await writeFile(join(outside, 'keep.txt'), 'outside')
await symlink(outside, join(staging, linked), 'junction')
const recovered = new RuntimeExtensionStore(
userDataPath,
dependencies
)
await recovered.getSnapshot()
expect(await readdir(staging)).toEqual(
expect.arrayContaining([unrelated, linked])
)
expect(await readdir(staging)).not.toContain(abandoned)
expect(await readdir(staging)).not.toContain(abandonedBackup)
await expect(
readFile(join(staging, unrelated, 'keep.txt'), 'utf8')
).resolves.toBe('keep')
await expect(
readFile(join(outside, 'keep.txt'), 'utf8')
).resolves.toBe('outside')
})
it('rejects installer entrypoints outside the managed package', async () => {
const entry = catalogEntry()
const fixtureValue = await fixture({
+239 -33
View File
@@ -33,6 +33,9 @@ import {
const managedDirectoryName = 'runtime-extensions'
const stateFileName = 'store.json'
const journalFileName = '.mutation-journal.json'
const unjournaledStagingDirectoryPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(?:-previous|-removed)?$/iu
const version1StoredStateSchema = z
.object({
@@ -56,6 +59,19 @@ const storedStateFileSchema = z.union([
type StoredState = z.infer<typeof storedStateSchema>
const mutationJournalSchema = z
.object({
version: z.literal(1),
kind: z.enum(['install', 'remove']),
extensionId: runtimeExtensionIdSchema,
temporaryId: runtimeExtensionIdSchema,
before: storedStateSchema,
after: storedStateSchema
})
.strict()
type MutationJournal = z.infer<typeof mutationJournalSchema>
export interface RuntimeExtensionCatalog {
list(): Promise<readonly RuntimeExtensionCatalogEntry[]>
}
@@ -128,6 +144,7 @@ export class RuntimeExtensionStore {
readonly managedRoot: string
private readonly statePath: string
private readonly journalPath: string
private state?: StoredState
private stateLoad?: Promise<StoredState>
private canonicalRoot?: string
@@ -144,6 +161,7 @@ export class RuntimeExtensionStore {
}
this.managedRoot = resolve(userDataPath, managedDirectoryName)
this.statePath = join(this.managedRoot, stateFileName)
this.journalPath = join(this.managedRoot, journalFileName)
}
async getSnapshot(): Promise<RuntimeExtensionMarketplaceSnapshot> {
@@ -176,6 +194,7 @@ export class RuntimeExtensionStore {
): Promise<RuntimeExtensionApplyResult> {
const parsed = runtimeExtensionActionSchema.parse(action)
const changed = await this.serialize(async () => {
await this.reconcileMutationJournal()
switch (parsed.type) {
case 'set-marketplace-enabled':
return this.setMarketplaceEnabled(parsed.enabled)
@@ -306,6 +325,8 @@ export class RuntimeExtensionStore {
this.canonicalRoot = await realpath(this.managedRoot)
await this.createManagedDirectory('extensions')
await this.createManagedDirectory('.staging')
await this.reconcileMutationJournal()
await this.cleanupUnjournaledStagingDirectories()
}
private async loadCatalog(): Promise<RuntimeExtensionCatalogEntry[]> {
@@ -354,9 +375,12 @@ export class RuntimeExtensionStore {
`${temporaryId}-previous`
)
const finalDirectory = this.extensionDirectory(extensionId)
let previousMoved = false
let stagedMoved = false
try {
if (await this.pathExists(backupDirectory)) {
throw new Error(
'Extension upgrade backup path already exists'
)
}
const installedPackage = await this.dependencies.install({
entry,
destinationDirectory: stagedDirectory
@@ -366,12 +390,6 @@ export class RuntimeExtensionStore {
installedPackage.entrypoint
)
if (await this.pathExists(finalDirectory)) {
await rename(finalDirectory, backupDirectory)
previousMoved = true
}
await rename(stagedDirectory, finalDirectory)
stagedMoved = true
const entrypoint = resolve(
finalDirectory,
installedPackage.entrypoint
@@ -392,19 +410,29 @@ export class RuntimeExtensionStore {
? { integrity: installedPackage.integrity }
: {})
}
await this.persistAndSet(
this.replaceInstalled(state, installed)
)
if (previousMoved) {
await this.removeManagedTree(backupDirectory).catch(() => undefined)
const nextState = this.replaceInstalled(state, installed)
const journal: MutationJournal = {
version: 1,
kind: 'install',
extensionId,
temporaryId,
before: state,
after: nextState
}
await this.writeMutationJournal(journal)
if (await this.pathExists(finalDirectory)) {
await this.assertRealManagedDirectory(finalDirectory)
}
if (await this.pathExists(finalDirectory)) {
await rename(finalDirectory, backupDirectory)
}
await rename(stagedDirectory, finalDirectory)
await this.persistAndSet(nextState)
await this.removeManagedTree(backupDirectory)
await this.clearMutationJournal()
} catch (error) {
if (stagedMoved) {
await this.removeManagedTree(finalDirectory)
}
if (previousMoved) {
await rename(backupDirectory, finalDirectory)
}
this.state = undefined
await this.reconcileMutationJournal().catch(() => undefined)
throw error
} finally {
await this.removeManagedTree(stagedDirectory).catch(() => undefined)
@@ -488,27 +516,40 @@ export class RuntimeExtensionStore {
'.staging',
`${randomUUID()}-removed`
)
let moved = false
const temporaryId = trashDirectory
.slice(trashDirectory.lastIndexOf(sep) + 1)
.replace(/-removed$/u, '')
runtimeExtensionIdSchema.parse(temporaryId)
if (await this.pathExists(trashDirectory)) {
throw new Error('Extension removal staging path already exists')
}
const nextState: StoredState = {
...state,
installed: state.installed.filter(
(extension) => extension.id !== extensionId
)
}
await this.writeMutationJournal({
version: 1,
kind: 'remove',
extensionId,
temporaryId,
before: state,
after: nextState
})
if (await this.pathExists(finalDirectory)) {
await this.assertRealManagedDirectory(finalDirectory)
await rename(finalDirectory, trashDirectory)
moved = true
}
try {
await this.persistAndSet({
...state,
installed: state.installed.filter(
(extension) => extension.id !== extensionId
)
})
await this.persistAndSet(nextState)
} catch (error) {
if (moved) {
await rename(trashDirectory, finalDirectory)
}
this.state = undefined
await this.reconcileMutationJournal().catch(() => undefined)
throw error
}
if (moved) {
await this.removeManagedTree(trashDirectory).catch(() => undefined)
}
await this.removeManagedTree(trashDirectory)
await this.clearMutationJournal()
}
private requireInstalled(
@@ -549,6 +590,171 @@ export class RuntimeExtensionStore {
)
}
private writeMutationJournal(journal: MutationJournal): Promise<void> {
return writeJsonFileAtomically(
this.journalPath,
mutationJournalSchema.parse(journal)
)
}
private async clearMutationJournal(): Promise<void> {
await unlink(this.journalPath).catch((error: unknown) => {
if (!isMissingFileError(error)) {
throw error
}
})
}
private async reconcileMutationJournal(): Promise<void> {
let journal: MutationJournal
try {
const status = await lstat(this.journalPath)
if (
!status.isFile() ||
status.isSymbolicLink() ||
status.nlink > 1
) {
throw new Error(
'Extension mutation journal must be a regular file'
)
}
await this.assertExistingPathContained(this.journalPath)
journal = mutationJournalSchema.parse(
JSON.parse(await readFile(this.journalPath, 'utf8')) as unknown
)
} catch (error) {
if (isMissingFileError(error)) {
return
}
throw error
}
const stateStatus = await lstat(this.statePath)
if (
!stateStatus.isFile() ||
stateStatus.isSymbolicLink() ||
stateStatus.nlink > 1
) {
throw new Error('Extension store state must be a regular file')
}
await this.assertExistingPathContained(this.statePath)
const stored = storedStateFileSchema.parse(
JSON.parse(await readFile(this.statePath, 'utf8')) as unknown
)
if (stored.version !== 2) {
throw new Error(
'Extension mutation journal requires current store state'
)
}
const committed = isDeepStrictEqual(stored, journal.after)
const rolledBack = isDeepStrictEqual(stored, journal.before)
if (!committed && !rolledBack) {
throw new Error(
'Extension mutation journal does not match store state'
)
}
const finalDirectory = this.extensionDirectory(journal.extensionId)
const stagedDirectory = this.managedPath(
'.staging',
journal.temporaryId
)
const auxiliaryDirectory = this.managedPath(
'.staging',
journal.kind === 'install'
? `${journal.temporaryId}-previous`
: `${journal.temporaryId}-removed`
)
if (journal.kind === 'install') {
if (committed) {
await this.assertInstalledStateOnDisk(
this.requireInstalled(journal.after, journal.extensionId)
)
await this.removeManagedTree(auxiliaryDirectory)
await this.removeManagedTree(stagedDirectory)
} else {
const hadPrevious = journal.before.installed.some(
(extension) => extension.id === journal.extensionId
)
if (await this.pathExists(auxiliaryDirectory)) {
await this.assertRealManagedDirectory(auxiliaryDirectory)
await this.removeManagedTree(finalDirectory)
await rename(auxiliaryDirectory, finalDirectory)
} else if (!hadPrevious) {
await this.removeManagedTree(finalDirectory)
}
if (hadPrevious) {
await this.assertInstalledStateOnDisk(
this.requireInstalled(
journal.before,
journal.extensionId
)
)
}
await this.removeManagedTree(stagedDirectory)
}
} else if (committed) {
await this.removeManagedTree(finalDirectory)
await this.removeManagedTree(auxiliaryDirectory)
} else {
if (await this.pathExists(auxiliaryDirectory)) {
await this.assertRealManagedDirectory(auxiliaryDirectory)
await this.removeManagedTree(finalDirectory)
await rename(auxiliaryDirectory, finalDirectory)
}
await this.assertInstalledStateOnDisk(
this.requireInstalled(journal.before, journal.extensionId)
)
}
await this.clearMutationJournal()
this.state = stored
}
private async assertRealManagedDirectory(path: string): Promise<void> {
this.assertContained(this.managedRoot, path)
const status = await lstat(path)
if (!status.isDirectory() || status.isSymbolicLink()) {
throw new Error(
'Extension package path must be a real directory'
)
}
await this.assertExistingPathContained(path)
}
private async cleanupUnjournaledStagingDirectories(): Promise<void> {
const stagingDirectory = this.managedPath('.staging')
await this.assertRealManagedDirectory(stagingDirectory)
const entries = await readdir(stagingDirectory, {
withFileTypes: true
})
for (const entry of entries) {
if (
!entry.isDirectory() ||
entry.isSymbolicLink() ||
!unjournaledStagingDirectoryPattern.test(entry.name)
) {
continue
}
const directory = this.managedPath('.staging', entry.name)
await this.assertRealManagedDirectory(directory)
await this.removeManagedTree(directory)
}
}
private async assertInstalledStateOnDisk(
extension: RuntimeExtensionInstalledState
): Promise<void> {
this.assertExtensionEntrypoint(extension)
const directory = this.extensionDirectory(extension.id)
await this.assertRealManagedDirectory(directory)
const relativeEntrypoint = relative(
directory,
extension.entrypoint
).split(sep).join('/')
await this.resolveEntrypoint(directory, relativeEntrypoint)
}
private assertExtensionEntrypoint(
extension: RuntimeExtensionInstalledState
): void {