feat: add secure multi-runtime controls

Make agent backends configurable and governable with encrypted credentials, explicit context sharing, and tool approval boundaries.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-30 10:52:35 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 1b6178b841
commit 698a15ad14
27 changed files with 2465 additions and 91 deletions
+88 -46
View File
@@ -57,6 +57,7 @@ function getTextDelta(value: unknown): string | undefined {
}
export class BigtokenAgentRuntime implements AgentRuntime {
readonly requiresToolApproval = false
private readonly conversations = new Map<string, ConversationMessage[]>()
private readonly fetcher: typeof fetch
@@ -84,10 +85,38 @@ export class BigtokenAgentRuntime implements AgentRuntime {
]
}
private saveConversation(
conversationId: string,
messages: ConversationMessage[]
): void {
const retained: ConversationMessage[] = []
let bytes = 0
for (const message of messages.slice(-20).reverse()) {
const messageBytes = Buffer.byteLength(message.content)
if (bytes + messageBytes > 512 * 1024) {
break
}
retained.unshift(message)
bytes += messageBytes
}
this.conversations.delete(conversationId)
this.conversations.set(conversationId, retained)
while (this.conversations.size > 50) {
const oldest = this.conversations.keys().next().value
if (oldest) {
this.conversations.delete(oldest)
}
}
}
async *run(
request: AgentRequest,
signal: AbortSignal
): AsyncGenerator<AgentEvent, void, void> {
if (!this.options.apiKey) {
throw new Error('请先在设置中配置 Bigtoken API Key')
}
yield {
requestId: request.requestId,
type: 'status',
@@ -137,71 +166,84 @@ export class BigtokenAgentRuntime implements AgentRuntime {
let buffer = ''
let answer = ''
let completed = false
let streamEnded = false
while (!completed) {
const { done, value } = await reader.read()
buffer += decoder.decode(value, { stream: !done }).replaceAll(
'\r\n',
'\n'
)
try {
while (!completed) {
const { done, value } = await reader.read()
streamEnded = done
buffer += decoder.decode(value, { stream: !done }).replaceAll(
'\r\n',
'\n'
)
const blocks = buffer.split('\n\n')
buffer = blocks.pop() ?? ''
for (const block of blocks) {
const data = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
if (!data || data === '[DONE]') {
continue
if (Buffer.byteLength(buffer) > 1024 * 1024) {
throw new Error('Bigtoken 流式响应块超过安全限制')
}
let event: unknown
try {
event = JSON.parse(data)
} catch {
continue
}
const blocks = buffer.split('\n\n')
buffer = blocks.pop() ?? ''
const error = getErrorMessage(event)
if (error) {
throw new Error(error)
}
for (const block of blocks) {
const data = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
const delta = getTextDelta(event)
if (delta) {
answer += delta
yield {
requestId: request.requestId,
type: 'text',
delta
if (!data || data === '[DONE]') {
continue
}
let event: unknown
try {
event = JSON.parse(data)
} catch {
continue
}
const error = getErrorMessage(event)
if (error) {
throw new Error(error.slice(0, 1_000))
}
const delta = getTextDelta(event)
if (delta) {
answer += delta
yield {
requestId: request.requestId,
type: 'text',
delta
}
}
if (
event &&
typeof event === 'object' &&
'type' in event &&
event.type === 'message_stop'
) {
completed = true
break
}
}
if (
event &&
typeof event === 'object' &&
'type' in event &&
event.type === 'message_stop'
) {
if (done) {
completed = true
break
}
}
if (done) {
completed = true
} finally {
if (!streamEnded) {
await reader.cancel().catch(() => undefined)
}
reader.releaseLock()
}
if (!answer) {
throw new Error('Bigtoken 返回了空内容')
}
this.conversations.set(request.conversationId, [
this.saveConversation(request.conversationId, [
...messages,
{ role: 'assistant', content: answer }
])
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { ContinueAgentRuntime } from './continue-runtime'
describe('ContinueAgentRuntime', () => {
it('does not launch the CLI for an already-cancelled request', async () => {
const runtime = new ContinueAgentRuntime({
command: 'command-that-must-not-run',
defaultWorkspace: process.cwd()
})
const controller = new AbortController()
controller.abort(new Error('cancelled'))
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'test'
},
controller.signal
)
await expect(stream.next()).rejects.toThrow('cancelled')
})
})
+215
View File
@@ -0,0 +1,215 @@
import spawn from 'cross-spawn'
import type {
AgentEvent,
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
import type { AgentRuntime } from './runtime'
type ContinueRuntimeOptions = {
command: string
defaultWorkspace: string
}
function extractContinueText(output: string): string {
const trimmed = output.trim()
if (!trimmed) {
return ''
}
try {
const parsed: unknown = JSON.parse(trimmed)
if (parsed && typeof parsed === 'object') {
const record = parsed as Record<string, unknown>
for (const key of ['content', 'message', 'response', 'text']) {
const value = record[key]
if (typeof value === 'string') {
return value
}
}
}
} catch {
return trimmed
}
return trimmed
}
export class ContinueAgentRuntime implements AgentRuntime {
readonly requiresToolApproval = true
private readonly children = new Set<ReturnType<typeof spawn>>()
constructor(private readonly options: ContinueRuntimeOptions) {}
private 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'
])
killer.unref()
} else {
child.kill('SIGTERM')
}
}
private checkAvailability(): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn(this.options.command, ['--version'], {
cwd: this.options.defaultWorkspace,
env: {
...process.env,
FORCE_NO_TTY: '1'
},
stdio: 'ignore',
windowsHide: true
})
const timeout = setTimeout(() => {
child.kill()
resolve(false)
}, 2_000)
child.once('error', () => {
clearTimeout(timeout)
resolve(false)
})
child.once('exit', (code) => {
clearTimeout(timeout)
resolve(code === 0)
})
})
}
async getStatus(): Promise<AgentRuntimeStatus> {
const available = await this.checkAvailability()
return {
id: 'continue',
label: 'Continue CLI',
available,
detail: available
? '通过 Continue CLI headless 模式执行'
: 'Continue CLI 不可用'
}
}
async *run(
request: AgentRequest,
signal: AbortSignal
): AsyncGenerator<AgentEvent, void, void> {
signal.throwIfAborted()
yield {
requestId: request.requestId,
type: 'status',
message: 'Continue 正在执行任务'
}
const result = await new Promise<string>((resolve, reject) => {
signal.throwIfAborted()
const child = spawn(
this.options.command,
['-p', '--format', 'json', '--silent'],
{
cwd: this.options.defaultWorkspace,
env: {
...process.env,
CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1',
FORCE_NO_TTY: '1'
},
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
}
)
this.children.add(child)
const { stdin, stdout: childStdout, stderr: childStderr } = child
if (!stdin || !childStdout || !childStderr) {
this.terminate(child)
reject(new Error('Continue CLI 管道初始化失败'))
return
}
let stdout = ''
let stderr = ''
let outputExceeded = false
const abort = (): void => {
this.terminate(child)
reject(signal.reason)
}
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) {
abort()
return
}
childStdout.setEncoding('utf8')
childStderr.setEncoding('utf8')
childStdout.on('data', (chunk: string) => {
stdout += chunk
if (Buffer.byteLength(stdout) > 4 * 1024 * 1024) {
outputExceeded = true
this.terminate(child)
}
})
childStderr.on('data', (chunk: string) => {
stderr += chunk
if (Buffer.byteLength(stderr) > 64 * 1024) {
outputExceeded = true
this.terminate(child)
}
})
child.once('error', (error) => {
this.children.delete(child)
signal.removeEventListener('abort', abort)
reject(error)
})
child.once('close', (code) => {
this.children.delete(child)
signal.removeEventListener('abort', abort)
if (outputExceeded) {
reject(new Error('Continue CLI 输出超过安全限制'))
} else if (code === 0) {
resolve(stdout)
} else {
reject(
new Error(
stderr.trim().slice(0, 1_000) ||
`Continue CLI 已退出(code ${code ?? 'unknown'}`
)
)
}
})
stdin.end(request.prompt)
})
const text = extractContinueText(result)
if (!text) {
throw new Error('Continue CLI 未返回内容')
}
yield {
requestId: request.requestId,
type: 'text',
delta: text
}
yield {
requestId: request.requestId,
type: 'done'
}
}
async dispose(): Promise<void> {
await Promise.all(
[...this.children].map(
(child) =>
new Promise<void>((resolve) => {
child.once('close', () => resolve())
this.terminate(child)
setTimeout(resolve, 2_000)
})
)
)
this.children.clear()
}
}
+22 -7
View File
@@ -1,13 +1,27 @@
import { BigtokenAgentRuntime } from './bigtoken-runtime'
import { ContinueAgentRuntime } from './continue-runtime'
import { DemoAgentRuntime } from './demo-runtime'
import { OpenCodeRuntime } from './opencode-runtime'
import type { AgentRuntime } from './runtime'
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
import { defaultRuntimeSettings } from '../../shared/contracts'
export function createAgentRuntime(defaultWorkspace: string): AgentRuntime {
export function createAgentRuntime(
defaultWorkspace: string,
settings?: ResolvedRuntimeSettings
): AgentRuntime {
const baseUrl = process.env.GOODBUDDY_OPENCODE_URL
const embedded = process.env.GOODBUDDY_OPENCODE_EMBEDDED === 'true'
const provider = settings?.provider ?? 'auto'
if (baseUrl || embedded) {
if (provider === 'continue') {
return new ContinueAgentRuntime({
command: process.env.GOODBUDDY_CONTINUE_COMMAND ?? 'cn',
defaultWorkspace
})
}
if (provider === 'opencode' || (provider === 'auto' && (baseUrl || embedded))) {
return new OpenCodeRuntime({
baseUrl,
embedded,
@@ -15,13 +29,14 @@ export function createAgentRuntime(defaultWorkspace: string): AgentRuntime {
})
}
const bigtokenApiKey = process.env.GOODBUDDY_BIGTOKEN_API_KEY?.trim()
if (bigtokenApiKey) {
const bigtokenApiKey =
settings?.apiKey ?? process.env.GOODBUDDY_BIGTOKEN_API_KEY?.trim()
if (provider === 'bigtoken' || (provider === 'auto' && bigtokenApiKey)) {
return new BigtokenAgentRuntime({
apiKey: bigtokenApiKey,
apiKey: bigtokenApiKey ?? '',
baseUrl:
process.env.GOODBUDDY_BIGTOKEN_BASE_URL ?? 'https://bigtoken.ai',
model: process.env.GOODBUDDY_BIGTOKEN_MODEL ?? 'sonnet-5'
settings?.bigtokenBaseUrl ?? defaultRuntimeSettings.bigtokenBaseUrl,
model: settings?.bigtokenModel ?? defaultRuntimeSettings.bigtokenModel
})
}
+2
View File
@@ -25,6 +25,8 @@ function wait(milliseconds: number, signal: AbortSignal): Promise<void> {
}
export class DemoAgentRuntime implements AgentRuntime {
readonly requiresToolApproval = false
async getStatus(): Promise<AgentRuntimeStatus> {
return {
id: 'demo',
+2 -1
View File
@@ -19,6 +19,7 @@ export type OpenCodeRuntimeOptions = {
}
export class OpenCodeRuntime implements AgentRuntime {
readonly requiresToolApproval = true
private client?: OpencodeClient
private server?: OpenCodeServer
private readonly sessions = new Map<string, string>()
@@ -108,7 +109,7 @@ export class OpenCodeRuntime implements AgentRuntime {
signal: AbortSignal
): AsyncGenerator<AgentEvent, void, void> {
const client = await this.getClient()
const directory = request.workspace ?? this.options.defaultWorkspace
const directory = this.options.defaultWorkspace
const sessionId = await this.getSessionId(client, request, directory)
yield {
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest'
import type {
AgentEvent,
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
import type { AgentRuntime } from './runtime'
import { AgentRuntimeController } from './runtime-controller'
class TestRuntime implements AgentRuntime {
readonly dispose = vi.fn(async () => {})
readonly started: Promise<void>
private release?: () => void
private markStarted!: () => void
constructor(
private readonly delayed = false,
readonly requiresToolApproval = false
) {
this.started = new Promise((resolve) => {
this.markStarted = resolve
})
}
getStatus(): Promise<AgentRuntimeStatus> {
return Promise.resolve({
id: 'demo',
label: 'Test',
available: true,
detail: 'Test runtime'
})
}
async *run(
request: AgentRequest
): AsyncGenerator<AgentEvent, void, void> {
this.markStarted()
if (this.delayed) {
await new Promise<void>((resolve) => {
this.release = resolve
})
}
yield {
requestId: request.requestId,
type: 'text',
delta: 'old runtime event'
}
}
finish(): void {
this.release?.()
}
}
describe('AgentRuntimeController', () => {
it('suppresses retired runtime events and disposes it after requests exit', async () => {
const previous = new TestRuntime(true, true)
const next = new TestRuntime()
const controller = new AgentRuntimeController(previous)
const authorize = vi.fn(async () => {})
const approvedStream = controller.run(
{
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b08',
conversationId: 'conversation-2',
prompt: 'test'
},
new AbortController().signal,
authorize
)
const pendingEvent = approvedStream.next()
await previous.started
expect(authorize).toHaveBeenCalledWith(true)
const replacement = controller.replace(next)
previous.finish()
await expect(pendingEvent).resolves.toMatchObject({ done: true })
await replacement
expect(previous.dispose).toHaveBeenCalledOnce()
await expect(controller.getStatus()).resolves.toMatchObject({
label: 'Test'
})
})
})
+123
View File
@@ -0,0 +1,123 @@
import type {
AgentEvent,
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
import type { AgentRuntime } from './runtime'
type RuntimeSlot = {
runtime: AgentRuntime
activeRequests: number
retiring: boolean
disposal?: Promise<void>
resolveDisposal?: () => void
}
export class AgentRuntimeController implements AgentRuntime {
private current: RuntimeSlot
private replacementQueue: Promise<void> = Promise.resolve()
private closing = false
constructor(runtime: AgentRuntime) {
this.current = {
runtime,
activeRequests: 0,
retiring: false
}
}
get requiresToolApproval(): boolean {
return this.current.runtime.requiresToolApproval
}
replace(next: AgentRuntime): Promise<void> {
if (this.closing) {
return next.dispose().then(() => {
throw new Error('Agent Runtime 正在关闭')
})
}
const operation = this.replacementQueue.then(() =>
this.performReplace(next)
)
this.replacementQueue = operation.catch(() => undefined)
return operation
}
private async performReplace(next: AgentRuntime): Promise<void> {
const previous = this.current
this.current = {
runtime: next,
activeRequests: 0,
retiring: false
}
const disposal = this.retire(previous)
await Promise.race([
disposal,
new Promise<void>((resolve) => setTimeout(resolve, 2_000))
])
}
getStatus(): Promise<AgentRuntimeStatus> {
return this.current.runtime.getStatus()
}
async *run(
request: AgentRequest,
signal: AbortSignal,
authorize?: (requiresToolApproval: boolean) => Promise<void>
): AsyncGenerator<AgentEvent, void, void> {
const slot = this.current
slot.activeRequests += 1
try {
await authorize?.(slot.runtime.requiresToolApproval)
for await (const event of slot.runtime.run(request, signal)) {
if (slot !== this.current) {
return
}
yield event
}
} finally {
slot.activeRequests -= 1
if (slot.retiring && slot.activeRequests === 0) {
await this.disposeSlot(slot)
}
}
}
private retire(slot: RuntimeSlot): Promise<void> {
slot.retiring = true
if (!slot.disposal) {
slot.disposal = new Promise((resolve) => {
slot.resolveDisposal = resolve
})
}
if (slot.activeRequests === 0) {
void this.disposeSlot(slot)
}
return slot.disposal
}
private async disposeSlot(slot: RuntimeSlot): Promise<void> {
if (!slot.resolveDisposal) {
return
}
const resolve = slot.resolveDisposal
slot.resolveDisposal = undefined
try {
await slot.runtime.dispose()
} catch {
resolve()
return
}
resolve()
}
async dispose(): Promise<void> {
this.closing = true
const operation = this.replacementQueue.then(() =>
this.retire(this.current)
)
this.replacementQueue = operation.catch(() => undefined)
await operation
}
}
+3 -1
View File
@@ -5,10 +5,12 @@ import type {
} from '../../shared/contracts'
export interface AgentRuntime {
readonly requiresToolApproval: boolean
getStatus(): Promise<AgentRuntimeStatus>
run(
request: AgentRequest,
signal: AbortSignal
signal: AbortSignal,
authorize?: (requiresToolApproval: boolean) => Promise<void>
): AsyncGenerator<AgentEvent, void, void>
dispose(): Promise<void>
}