feat: strengthen private runtime and adaptive UI behavior

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-02 21:51:53 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b3fdf96962
commit e8dc4d03fd
32 changed files with 1390 additions and 128 deletions
+100 -1
View File
@@ -130,6 +130,22 @@ describe('ContinueHostAdapter', () => {
)
})
it('blocks runs without an explicit model profile or config file', async () => {
const launchHost = vi.fn()
const adapter = new ContinueHostAdapter({
binaryPath: 'C:\\unused\\cn.js',
configPath: '',
workspace: process.cwd(),
cacheRoot: 'C:\\unused\\cache',
launchHost: launchHost as unknown as ContinueHostLauncher
})
await expect(
adapter.run('hello', new AbortController().signal, async () => 'deny')
).rejects.toThrow('尚未配置模型连接')
expect(launchHost).not.toHaveBeenCalled()
})
it('launches the prepared host through the injected launcher', async () => {
const distribution = await createDistribution()
let launch:
@@ -266,10 +282,18 @@ describe('ContinueHostAdapter', () => {
CONTINUE_CLI_ENABLE_TELEMETRY: '0',
CONTINUE_METRICS_ENABLED: '0',
CONTINUE_GLOBAL_DIR: expect.stringContaining('isolated-global'),
DO_NOT_TRACK: '1',
GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1',
OTEL_EXPORTER_OTLP_ENDPOINT: '',
OTEL_EXPORTER_OTLP_HEADERS: '',
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: '',
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: '',
OTEL_LOG_USER_PROMPTS: '0'
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: '',
OTEL_LOGS_EXPORTER: 'none',
OTEL_LOG_USER_PROMPTS: '0',
OTEL_METRICS_EXPORTER: 'none',
OTEL_SDK_DISABLED: 'true',
OTEL_TRACES_EXPORTER: 'none'
})
expect(killed).toBe(true)
expect(JSON.parse(generatedConfig)).toMatchObject({
@@ -391,4 +415,79 @@ describe('ContinueHostAdapter', () => {
expect(launchedEnvironment).not.toHaveProperty('OPENAI_API_KEY')
expect(launchedEnvironment).not.toHaveProperty('ANTHROPIC_API_KEY')
})
it('turns a strict upstream error envelope into a failed run', async () => {
const distribution = await createDistribution()
let killed = false
const launchHost: ContinueHostLauncher = () => ({
exitCode: null,
get killed() {
return killed
},
stderr: null,
once: () => undefined,
kill: () => {
killed = true
return true
}
})
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: 'Partial response'
}
},
{
message: {
role: 'system',
content:
'Error: {"error":{"message":"Request not allowed"}}'
}
}
]
},
isProcessing: false,
messageQueueLength: 0,
pendingPermission: null
})
}
return Response.json({})
})
)
const adapter = new ContinueHostAdapter({
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost,
modelProfile: {
id: '00000000-0000-4000-8000-000000000013',
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'local-model',
protocol: 'openai-chat-completions',
authentication: 'none'
}
})
await expect(
adapter.run('hello', new AbortController().signal, async () => 'deny')
).rejects.toThrow(
'Continue 模型请求失败:Request not allowed'
)
expect(killed).toBe(true)
})
})
+86 -8
View File
@@ -29,10 +29,16 @@ import {
createContinuePermissionRule
} from './continue-permissions'
import { getAvailableLoopbackPort } from './loopback-port'
import { buildRuntimeEnvironment } from './process-environment'
import {
buildRuntimeEnvironment,
runtimePrivacyEnvironment
} from './process-environment'
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
import { createOpenAIApiBaseUrl } from './openai-endpoint'
import { safeToolArgumentSummary } from './approval-summary'
import {
redactSensitiveText,
safeToolArgumentSummary
} from './approval-summary'
const supportedVersion = '1.5.47'
const supportedBundleHashes = new Set([
@@ -40,6 +46,8 @@ const supportedBundleHashes = new Set([
])
const maximumBundleBytes = 32 * 1024 * 1024
const maximumStateBytes = 8 * 1024 * 1024
export const continueConfigurationRequiredMessage =
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
const utilityBootstrap = [
"import { pathToFileURL } from 'node:url'",
'const entryPath = process.argv[2]',
@@ -115,6 +123,13 @@ export type ContinueHostAdapterOptions = {
modelProfile?: ResolvedModelProfile
}
export function hasContinueModelConfiguration(
configPath: string,
modelProfile?: ResolvedModelProfile
): boolean {
return Boolean(modelProfile || configPath.trim())
}
export type ContinueHostChild = {
exitCode: number | null
killed: boolean
@@ -229,6 +244,58 @@ function extractAssistantText(
return ''
}
function parseContinueFailure(text: string): string | undefined {
const match = /^Error:\s*(\{[\s\S]{1,16384}\})$/u.exec(text.trim())
if (!match?.[1]) {
return undefined
}
try {
const payload = JSON.parse(match[1]) as unknown
if (!payload || typeof payload !== 'object') {
return undefined
}
const record = payload as Record<string, unknown>
const error = record.error
const message =
typeof error === 'string'
? error
: error && typeof error === 'object'
? (error as Record<string, unknown>).message
: record.message
const detail =
typeof message === 'string' && message.trim()
? `${redactSensitiveText(message.trim()).slice(0, 500)}`
: ''
return `Continue 模型请求失败${detail}`
} catch {
return undefined
}
}
function extractContinueFailure(
history: unknown[],
startIndex: number
): string | undefined {
for (const item of history.slice(startIndex).reverse()) {
if (!item || typeof item !== 'object') {
continue
}
const message = (item as Record<string, unknown>).message
if (!message || typeof message !== 'object') {
continue
}
const content = (message as Record<string, unknown>).content
if (typeof content !== 'string') {
continue
}
const failure = parseContinueFailure(content)
if (failure) {
return failure
}
}
return undefined
}
function subtractTokenCount(completed: number, initial: number): number {
return Math.max(0, completed - initial)
}
@@ -480,6 +547,14 @@ export class ContinueHostAdapter {
authorize: RuntimeAuthorizer
): Promise<ContinueHostRunResult> {
signal.throwIfAborted()
if (
!hasContinueModelConfiguration(
this.options.configPath,
this.options.modelProfile
)
) {
throw new Error(continueConfigurationRequiredMessage)
}
let generatedConfigPath: string | undefined
if (this.options.modelProfile) {
if (
@@ -547,6 +622,7 @@ export class ContinueHostAdapter {
}
args.push('serve', '--port', String(port), '--timeout', '300')
const environment = buildRuntimeEnvironment({
...runtimePrivacyEnvironment,
CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1',
CONTINUE_CLI_AUTO_UPDATED: '1',
CONTINUE_CLI_ENABLE_TELEMETRY: '0',
@@ -554,12 +630,7 @@ export class ContinueHostAdapter {
CONTINUE_GLOBAL_DIR: isolatedGlobalDirectory,
FORCE_NO_TTY: '1',
GOODBUDDY_CONTINUE_HOST_TOKEN: token,
GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1',
OTEL_EXPORTER_OTLP_ENDPOINT: '',
OTEL_EXPORTER_OTLP_HEADERS: '',
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: '',
OTEL_METRICS_EXPORTER: '',
OTEL_LOG_USER_PROMPTS: '0'
GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1'
})
if (this.options.modelProfile) {
delete environment.ANTHROPIC_API_KEY
@@ -699,6 +770,13 @@ export class ContinueHostAdapter {
!state.pendingPermission &&
state.session.history.length > startIndex
) {
const failure = extractContinueFailure(
state.session.history,
startIndex
)
if (failure) {
throw new Error(failure)
}
const text = extractAssistantText(
state.session.history,
startIndex
+34 -1
View File
@@ -152,7 +152,7 @@ describe('ContinueAgentRuntime', () => {
it('adds assigned Skill instructions to the Continue prompt', async () => {
const runtime = new ContinueAgentRuntime({
binaryPath: '',
configPath: '',
configPath: 'C:\\safe config\\continue.yaml',
mode: 'chat',
defaultWorkspace: process.cwd(),
hostCacheRoot: 'C:\\safe\\continue-host',
@@ -172,6 +172,39 @@ describe('ContinueAgentRuntime', () => {
expect(prompt).toContain('test')
})
it('blocks anonymous platform fallback without an explicit model configuration', async () => {
const runtime = new ContinueAgentRuntime({
binaryPath: '',
configPath: '',
mode: 'chat',
defaultWorkspace: process.cwd(),
hostCacheRoot: 'C:\\safe\\continue-host',
createHostAdapter: () => ({
getPreparedHost: mocks.prepareHost,
run: mocks.runHost,
dispose: mocks.disposeHost
})
})
await expect(runtime.getStatus()).resolves.toMatchObject({
available: false,
detail: expect.stringContaining('尚未配置模型连接')
})
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'test'
},
new AbortController().signal,
vi.fn(async () => 'once' as const)
)
await expect(stream.next()).rejects.toThrow('尚未配置模型连接')
expect(mocks.detectRuntimeBinary).not.toHaveBeenCalled()
expect(mocks.prepareHost).not.toHaveBeenCalled()
expect(mocks.runHost).not.toHaveBeenCalled()
})
it('places the current request before untrusted conversation history', async () => {
const runtime = createRuntime()
for await (const _event of runtime.run(
+24
View File
@@ -13,6 +13,8 @@ import { detectRuntimeBinary } from './runtime-discovery'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import {
ContinueHostAdapter,
continueConfigurationRequiredMessage,
hasContinueModelConfiguration,
type ContinueHostAdapterOptions,
type ContinueHostLauncher
} from './continue-host-adapter'
@@ -137,6 +139,20 @@ export class ContinueAgentRuntime implements AgentRuntime {
'Continue 宿主暂不支持严格 OS 沙箱,请改用自动模式或嵌入式 OpenCode'
}
}
if (
!hasContinueModelConfiguration(
this.options.configPath,
this.options.modelProfile
)
) {
return {
id: 'continue',
label: 'Continue CLI',
available: false,
supportsToolExecution: this.supportsToolExecution,
detail: continueConfigurationRequiredMessage
}
}
const detection = await this.getDetection()
if (detection.available && detection.path) {
try {
@@ -179,6 +195,14 @@ export class ContinueAgentRuntime implements AgentRuntime {
if (request.images?.length) {
throw new Error('Continue Runtime 暂不支持图片上下文,请切换到视觉模型')
}
if (
!hasContinueModelConfiguration(
this.options.configPath,
this.options.modelProfile
)
) {
throw new Error(continueConfigurationRequiredMessage)
}
const prompt = buildContinuePrompt(request)
const skillPrefix = this.options.skillInstructions
? [
+4
View File
@@ -439,6 +439,10 @@ describe('OpenCodeRuntime embedded launcher', () => {
OPENCODE_DISABLE_MODELS_FETCH: '1',
OPENCODE_DISABLE_SHARE: '1',
OTEL_EXPORTER_OTLP_ENDPOINT: '',
OTEL_EXPORTER_OTLP_HEADERS: '',
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: '',
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: '',
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: '',
OTEL_SDK_DISABLED: 'true'
})
await runtime.dispose()
+5 -9
View File
@@ -21,7 +21,10 @@ 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 } from './process-environment'
import {
buildRuntimeEnvironment,
runtimePrivacyEnvironment
} from './process-environment'
import {
buildBubblewrapLaunch,
type RuntimeSandboxResolution
@@ -366,7 +369,7 @@ export class OpenCodeRuntime implements AgentRuntime {
throw new Error('OpenCode Server 启动已取消')
}
const env = buildRuntimeEnvironment({})
const env = buildRuntimeEnvironment(runtimePrivacyEnvironment)
if (this.options.modelProfile && !this.options.modelProfile.apiKey) {
throw new Error('OpenCode 独立模型连接尚未配置 API Key')
}
@@ -380,18 +383,11 @@ export class OpenCodeRuntime implements AgentRuntime {
).toString('base64')}`
env.OPENCODE_SERVER_USERNAME = EMBEDDED_SERVER_USERNAME
env.OPENCODE_SERVER_PASSWORD = serverPassword
env.DO_NOT_TRACK = '1'
env.OPENCODE_DISABLE_AUTOUPDATE = '1'
env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = '1'
env.OPENCODE_DISABLE_LSP_DOWNLOAD = '1'
env.OPENCODE_DISABLE_MODELS_FETCH = '1'
env.OPENCODE_DISABLE_SHARE = '1'
env.OTEL_EXPORTER_OTLP_ENDPOINT = ''
env.OTEL_EXPORTER_OTLP_HEADERS = ''
env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = ''
env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = ''
env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = ''
env.OTEL_SDK_DISABLED = 'true'
if (this.options.modelProfile) {
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
model: `anthropic/${this.options.modelProfile.modelName}`,
+14
View File
@@ -38,6 +38,20 @@ const runtimeEnvironmentAllowlist = [
'COHERE_API_KEY'
] as const
export const runtimePrivacyEnvironment: NodeJS.ProcessEnv = {
DO_NOT_TRACK: '1',
OTEL_EXPORTER_OTLP_ENDPOINT: '',
OTEL_EXPORTER_OTLP_HEADERS: '',
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: '',
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: '',
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: '',
OTEL_LOGS_EXPORTER: 'none',
OTEL_LOG_USER_PROMPTS: '0',
OTEL_METRICS_EXPORTER: 'none',
OTEL_SDK_DISABLED: 'true',
OTEL_TRACES_EXPORTER: 'none'
}
export function buildRuntimeEnvironment(
overrides: NodeJS.ProcessEnv,
source: NodeJS.ProcessEnv = process.env