feat: expand secure runtime and workspace UX

This commit is contained in:
lofyer
2026-08-04 00:57:08 +08:00
parent 09e9fbf5e2
commit 2910d315f7
56 changed files with 6164 additions and 882 deletions
+73 -141
View File
@@ -13,13 +13,11 @@ import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
import type {
AgentExecutionRequest,
AgentRuntime,
RuntimeAuthorizer,
RuntimeEvent,
RuntimeModelUsageEvent
} from './runtime'
import { detectRuntimeBinary } from './runtime-discovery'
import { getAvailableLoopbackPort } from './loopback-port'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import {
buildRuntimeEnvironment,
@@ -29,10 +27,7 @@ import {
buildBubblewrapLaunch,
type RuntimeSandboxResolution
} from './runtime-sandbox'
import {
redactSensitiveText,
safeToolArgumentSummary
} from './approval-summary'
import { redactSensitiveText } from './approval-summary'
const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024
const STARTUP_TIMEOUT_MS = 10_000
@@ -41,7 +36,7 @@ const MAX_PERMISSION_PATTERNS = 32
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_PERMISSION_SUMMARY_LENGTH = 2_000
const MAX_TOOL_CALLS_PER_RUN = 100
const EMBEDDED_SERVER_USERNAME = 'goodbuddy'
type SpawnedProcess = ReturnType<typeof spawn>
@@ -128,7 +123,11 @@ function parsePermissionRequest(
(tool !== undefined &&
(!isRecord(tool) ||
typeof tool.messageID !== 'string' ||
typeof tool.callID !== 'string'))
tool.messageID.length === 0 ||
tool.messageID.length > 256 ||
typeof tool.callID !== 'string' ||
tool.callID.length === 0 ||
tool.callID.length > 256))
) {
throw new Error('OpenCode 权限请求格式无效')
}
@@ -149,23 +148,6 @@ function parsePermissionRequest(
return properties as PermissionRequest
}
function permissionArgumentSummary(
request: PermissionRequest
): string {
return safeToolArgumentSummary(
{
patterns: request.patterns,
metadata: request.metadata
},
undefined,
MAX_PERMISSION_SUMMARY_LENGTH
)
}
function permissionScopeKey(request: PermissionRequest): string {
return `opencode:${request.permission}`
}
function isSafeTokenCount(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0
}
@@ -224,7 +206,6 @@ export type OpenCodeRuntimeOptions = {
defaultWorkspace: string
modelProfile?: ResolvedModelProfile
skillInstructions?: string
mcpServers?: ResolvedMcpServer[]
sandbox?: RuntimeSandboxResolution
}
@@ -279,9 +260,8 @@ function parseListeningUrl(output: string): string | undefined {
}
export class OpenCodeRuntime implements AgentRuntime {
get requiresToolApproval(): boolean {
return !this.usesEmbeddedPermissionMediation()
}
readonly runtimeId = 'opencode'
readonly requiresToolApproval = false
readonly supportsToolExecution = true
private client?: OpencodeClient
private clientInitialization?: Promise<OpencodeClient>
@@ -292,9 +272,6 @@ export class OpenCodeRuntime implements AgentRuntime {
string,
Promise<string>
>()
private readonly configuredMcpNames = new Set<string>()
private capabilitiesConfigured = false
private capabilityInitialization?: Promise<void>
private readonly dependencies: OpenCodeRuntimeDependencies
constructor(
@@ -653,69 +630,15 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
private async configureCapabilities(
client: OpencodeClient
): Promise<void> {
if (this.capabilitiesConfigured) {
return
}
this.capabilityInitialization ??=
this.performConfigureCapabilities(client)
try {
await this.capabilityInitialization
} catch (error) {
this.capabilityInitialization = undefined
throw error
}
}
private async performConfigureCapabilities(
client: OpencodeClient
): Promise<void> {
for (const server of this.options.mcpServers ?? []) {
const name = `goodbuddy-${server.id}`
const config =
server.transport === 'stdio'
? {
type: 'local' as const,
command: [server.command, ...server.args],
enabled: true,
timeout: 10_000
}
: {
type: 'remote' as const,
url: server.url,
enabled: true,
headers: server.secret
? { Authorization: `Bearer ${server.secret}` }
: undefined,
oauth: false as const,
timeout: 10_000
}
const response = await client.mcp.add({
name,
config,
directory: this.options.defaultWorkspace
})
if (response.error) {
throw new Error(`OpenCode 无法加载 MCP Server${server.name}`)
}
this.configuredMcpNames.add(name)
}
this.capabilitiesConfigured = true
}
async *run(
request: AgentExecutionRequest,
signal: AbortSignal,
authorize?: RuntimeAuthorizer
signal: AbortSignal
): AsyncGenerator<RuntimeEvent, void, void> {
signal.throwIfAborted()
if (request.images?.length) {
throw new Error('OpenCode Runtime 暂不支持图片上下文,请切换到视觉模型')
}
const client = await this.getClient(signal)
await this.configureCapabilities(client)
const directory = this.options.defaultWorkspace
const permission = this.usesEmbeddedPermissionMediation()
? request.workMode === 'execute'
@@ -770,6 +693,13 @@ export class OpenCodeRuntime implements AgentRuntime {
}
signal.addEventListener('abort', abortSession, { once: true })
const toolStates = new Map<
string,
{
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
}
>()
try {
const promptText =
session.created && request.history?.length
@@ -797,10 +727,6 @@ export class OpenCodeRuntime implements AgentRuntime {
const repliedPermissionIds = new Set<string>()
const reportedMessageIds = new Set<string>()
const toolStates = new Map<
string,
'pending' | 'running' | 'completed' | 'failed'
>()
for await (const event of subscription.stream) {
if (
event.type === 'message.updated' &&
@@ -838,11 +764,20 @@ export class OpenCodeRuntime implements AgentRuntime {
) {
const { part } = event.properties
if (part.type === 'tool') {
const callId = (part.callID || part.id).slice(0, 256)
const callId = part.callID || part.id
if (!callId || callId.length > 256) {
throw new Error('OpenCode 工具调用 ID 格式无效')
}
const toolName = part.tool.slice(0, 200)
if (
!toolStates.has(callId) &&
toolStates.size >= MAX_TOOL_CALLS_PER_RUN
) {
throw new Error('OpenCode 单次运行的工具调用超过 100 个')
}
const state =
part.state.status === 'error' ? 'failed' : part.state.status
toolStates.set(callId, state)
toolStates.set(callId, { name: toolName, state })
yield {
requestId: request.requestId,
type: 'tool',
@@ -882,6 +817,14 @@ export class OpenCodeRuntime implements AgentRuntime {
properties.id.length <= MAX_PERMISSION_NAME_LENGTH &&
!repliedPermissionIds.has(properties.id)
) {
if (
repliedPermissionIds.size >= MAX_TOOL_CALLS_PER_RUN
) {
throw new Error(
'OpenCode 单次运行的权限请求超过 100 个',
{ cause: error }
)
}
repliedPermissionIds.add(properties.id)
const rejection = await client.permission.reply({
requestID: properties.id,
@@ -901,44 +844,34 @@ export class OpenCodeRuntime implements AgentRuntime {
if (repliedPermissionIds.has(permissionRequest.id)) {
continue
}
if (repliedPermissionIds.size >= MAX_TOOL_CALLS_PER_RUN) {
throw new Error('OpenCode 单次运行的权限请求超过 100 个')
}
repliedPermissionIds.add(permissionRequest.id)
let decision: Awaited<ReturnType<NonNullable<typeof authorize>>>
try {
decision = authorize
? await authorize({
scopeKey: permissionScopeKey(permissionRequest),
title: `OpenCode 请求调用 ${permissionRequest.permission}`,
description:
'仅在你选择允许后,OpenCode 才会执行此工具调用。',
toolName: permissionRequest.permission,
argumentSummary:
permissionArgumentSummary(permissionRequest),
allowPermanent: false
})
: 'deny'
} catch (error) {
const rejection = await client.permission.reply({
requestID: permissionRequest.id,
directory,
reply: 'reject'
})
if (rejection.error || rejection.data !== true) {
throw new Error('OpenCode 权限拒绝回复失败', {
cause: error
})
}
throw error
const callId = (
permissionRequest.tool?.callID ?? permissionRequest.id
)
const toolName = permissionRequest.permission.slice(0, 200)
if (
!toolStates.has(callId) &&
toolStates.size >= MAX_TOOL_CALLS_PER_RUN
) {
throw new Error('OpenCode 单次运行的工具调用超过 100 个')
}
toolStates.set(callId, { name: toolName, state: 'pending' })
yield {
requestId: request.requestId,
type: 'tool',
callId,
name: toolName,
state: 'pending',
summary: `OpenCode 工具:${toolName}`
}
const reply =
decision === 'once' || decision === 'session'
? 'once'
: 'reject'
const response = await client.permission.reply({
requestID: permissionRequest.id,
directory,
reply
reply: 'once'
})
if (response.error || response.data !== true) {
throw new Error('OpenCode 权限回复失败')
@@ -969,12 +902,12 @@ export class OpenCodeRuntime implements AgentRuntime {
)
}
const unsuccessfulTool = [...toolStates.entries()].find(
([, state]) => state !== 'completed'
([, tool]) => tool.state !== 'completed'
)
if (unsuccessfulTool) {
const [callId, state] = unsuccessfulTool
const [callId, tool] = unsuccessfulTool
throw new Error(
state === 'failed'
tool.state === 'failed'
? `OpenCode 工具执行失败(${callId.slice(0, 128)}`
: `OpenCode 工具未完成(${callId.slice(0, 128)}`
)
@@ -1000,6 +933,18 @@ export class OpenCodeRuntime implements AgentRuntime {
throw new Error('OpenCode 事件流意外结束')
} catch (error) {
abortSession()
for (const [callId, tool] of toolStates) {
if (tool.state === 'pending' || tool.state === 'running') {
yield {
requestId: request.requestId,
type: 'tool',
callId,
name: tool.name,
state: 'failed',
summary: `OpenCode 工具:${tool.name}`
}
}
}
throw error
} finally {
signal.removeEventListener('abort', abortSession)
@@ -1014,24 +959,11 @@ export class OpenCodeRuntime implements AgentRuntime {
await this.waitForExit(startingChild)
}
const server = this.server
const client = this.client
this.server = undefined
this.client = undefined
this.clientInitialization = undefined
this.capabilityInitialization = undefined
this.sessions.clear()
this.sessionInitializations.clear()
await Promise.all(
[...this.configuredMcpNames].map((name) =>
client?.mcp
.disconnect({
name,
directory: this.options.defaultWorkspace
})
.catch(() => undefined)
)
)
this.configuredMcpNames.clear()
await server?.close()
}