1155 lines
34 KiB
TypeScript
1155 lines
34 KiB
TypeScript
import {
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
writeFile
|
|
} from 'node:fs/promises'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { createHash } from 'node:crypto'
|
|
import { createServer } from 'node:http'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
ContinueHostAdapter,
|
|
type ContinueHostLauncher
|
|
} from './continue-host-adapter'
|
|
|
|
const temporaryDirectories: string[] = []
|
|
const environmentRestorations: Array<() => void> = []
|
|
|
|
const inheritedProviderCredentials = {
|
|
ANTHROPIC_API_KEY: 'inherited-anthropic',
|
|
OPENAI_API_KEY: 'inherited-openai',
|
|
GOOGLE_GENERATIVE_AI_API_KEY: 'inherited-google',
|
|
GEMINI_API_KEY: 'inherited-gemini',
|
|
AWS_ACCESS_KEY_ID: 'inherited-aws-access',
|
|
AWS_SECRET_ACCESS_KEY: 'inherited-aws-secret',
|
|
AWS_SESSION_TOKEN: 'inherited-aws-session',
|
|
AWS_PROFILE: 'inherited-aws-profile',
|
|
OPENROUTER_API_KEY: 'inherited-openrouter'
|
|
} as const
|
|
|
|
function inheritProviderCredentials(): void {
|
|
const previousEnvironment = Object.fromEntries(
|
|
Object.keys(inheritedProviderCredentials).map((name) => [
|
|
name,
|
|
process.env[name]
|
|
])
|
|
)
|
|
Object.assign(process.env, inheritedProviderCredentials)
|
|
environmentRestorations.push(() => {
|
|
for (const [name, value] of Object.entries(previousEnvironment)) {
|
|
if (value === undefined) {
|
|
delete process.env[name]
|
|
} else {
|
|
process.env[name] = value
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
async function createDistribution(version = '1.5.47'): Promise<{
|
|
cacheRoot: string
|
|
entryPath: string
|
|
sourceHash: string
|
|
}> {
|
|
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-continue-host-'))
|
|
temporaryDirectories.push(root)
|
|
const distribution = join(root, 'package', 'dist')
|
|
const cacheRoot = join(root, 'cache')
|
|
await mkdir(distribution, { recursive: true })
|
|
await writeFile(
|
|
join(root, 'package', 'package.json'),
|
|
JSON.stringify({ version }),
|
|
'utf8'
|
|
)
|
|
await writeFile(join(distribution, 'cn.js'), 'import "./index.js"\n', 'utf8')
|
|
await writeFile(join(distribution, 'xhr-sync-worker.js'), '', 'utf8')
|
|
const sourceBundle = [
|
|
'toolPermissionOverrides:s,headless:!0});let[a,u,l,c]',
|
|
'i={allow:o.allow,ask:o.ask,exclude:o.exclude,isHeadless:e.headless}',
|
|
'E6t.initialize({isHeadless:e.headless},r,n)',
|
|
'function ZZo(e){let t=[];if(e.exclude)for(let n of e.exclude){let r=n;t.push({tool:r,permission:"exclude"})}if(e.ask)for(let n of e.ask){let r=n;t.push({tool:r,permission:"ask"})}if(e.allow)for(let n of e.allow){let r=n;t.push({tool:r,permission:"allow"})}return t}',
|
|
'let j=(0,atn.default)();j.use(atn.default.json()),j.get("/state"',
|
|
'listen(i,async()=>{console.log(Ht.green(`Server started on http://localhost:${i}`))',
|
|
'async function SCt(e){return n5e||',
|
|
'shouldUseResponsesEndpoint(t){return this.config.useResponsesApi===!1?!1:this.apiBase==="https://api.openai.com/v1/"&&A0e(t)}',
|
|
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env};return CGn(n)??null}',
|
|
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-ExecutionPolicy","Bypass","-Command",e]}',
|
|
'a={onContent:u=>{},onContentComplete:u=>{},onToolStart:(u,l)=>{},onToolResult:(u,l,c)=>{},onToolError:(u,l)=>{},onToolPermissionRequest:',
|
|
'pendingPermission:null},B=',
|
|
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission);Te.json(ue)})',
|
|
'n?.onToolStart?.(i.name,i.arguments);',
|
|
'n?.onToolError?.(l,i.name)',
|
|
't?.onToolStart?.(c.name,c.arguments);',
|
|
't?.onToolResult?.(String(y.content),c.name,"canceled")',
|
|
't?.onToolResult?.(f,c.name,"done")',
|
|
't?.onToolError?.(g,c.name)',
|
|
't?.onToolError?.(p,c.name)'
|
|
].join(';')
|
|
await writeFile(join(distribution, 'index.js'), sourceBundle, 'utf8')
|
|
return {
|
|
cacheRoot,
|
|
entryPath: join(distribution, 'cn.js'),
|
|
sourceHash: createHash('sha256')
|
|
.update(sourceBundle)
|
|
.digest('hex')
|
|
}
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals()
|
|
for (const restoreEnvironment of environmentRestorations.splice(0)) {
|
|
restoreEnvironment()
|
|
}
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) =>
|
|
rm(directory, { recursive: true, force: true })
|
|
)
|
|
)
|
|
})
|
|
|
|
describe('ContinueHostAdapter', () => {
|
|
it('creates a versioned authenticated loopback host copy', async () => {
|
|
const distribution = await createDistribution()
|
|
const adapter = new ContinueHostAdapter({
|
|
binaryPath: distribution.entryPath,
|
|
configPath: '',
|
|
workspace: process.cwd(),
|
|
cacheRoot: distribution.cacheRoot,
|
|
trustedBundleHashes: [distribution.sourceHash]
|
|
})
|
|
|
|
const prepared = await adapter.getPreparedHost()
|
|
const bundle = await readFile(
|
|
join(prepared.entryPath, '..', 'index.js'),
|
|
'utf8'
|
|
)
|
|
const bootstrap = await readFile(
|
|
join(prepared.entryPath, '..', 'utility-bootstrap.mjs'),
|
|
'utf8'
|
|
)
|
|
|
|
expect(prepared.version).toBe('1.5.47')
|
|
expect(bundle).toContain('interactivePermissions:!0')
|
|
expect(bundle).toContain(
|
|
'isHeadless:e.interactivePermissions?!1:e.headless'
|
|
)
|
|
expect(bundle).toContain('GOODBUDDY_CONTINUE_HOST_TOKEN')
|
|
expect(bundle).toContain('listen(i,"127.0.0.1"')
|
|
expect(bundle).toContain(
|
|
'GOODBUDDY_DISABLE_CONTINUE_UPDATES'
|
|
)
|
|
expect(bundle).toContain(
|
|
'this.config.useResponsesApi===!0?!0'
|
|
)
|
|
expect(bundle).toContain(
|
|
'useResponsesApi:e.useResponsesApi'
|
|
)
|
|
expect(bundle).toContain('"-NoProfile"')
|
|
expect(bundle).toContain('[Console]::OutputEncoding')
|
|
expect(bundle).toContain('goodbuddyEvents:[]')
|
|
expect(bundle).toContain('goodbuddyEvents:ce')
|
|
expect(bundle).toContain('type:"text",delta:u')
|
|
expect(bundle).toContain('onToolStart?.(c.name,c.arguments,c.id)')
|
|
expect(bundle).toContain(
|
|
'function ZZo(e){let t=[];if(e.allow)'
|
|
)
|
|
expect(bundle).not.toContain(
|
|
'toolPermissionOverrides:s,headless:!0});let'
|
|
)
|
|
expect(bootstrap).toContain(
|
|
'process.argv = process.argv.slice(2)'
|
|
)
|
|
})
|
|
|
|
it('rejects unsupported Continue versions without patching them', async () => {
|
|
const distribution = await createDistribution('1.6.0')
|
|
const adapter = new ContinueHostAdapter({
|
|
binaryPath: distribution.entryPath,
|
|
configPath: '',
|
|
workspace: process.cwd(),
|
|
cacheRoot: distribution.cacheRoot,
|
|
trustedBundleHashes: [distribution.sourceHash]
|
|
})
|
|
|
|
await expect(adapter.getPreparedHost()).rejects.toThrow(
|
|
'仅支持 1.5.47'
|
|
)
|
|
})
|
|
|
|
it('rejects an untrusted bundle even when markers and version match', async () => {
|
|
const distribution = await createDistribution()
|
|
const adapter = new ContinueHostAdapter({
|
|
binaryPath: distribution.entryPath,
|
|
configPath: '',
|
|
workspace: process.cwd(),
|
|
cacheRoot: distribution.cacheRoot,
|
|
trustedBundleHashes: ['0'.repeat(64)]
|
|
})
|
|
|
|
await expect(adapter.getPreparedHost()).rejects.toThrow(
|
|
'兼容性校验'
|
|
)
|
|
})
|
|
|
|
it('removes capability config when host preparation fails after generation', async () => {
|
|
const distribution = await createDistribution()
|
|
const adapter = new ContinueHostAdapter({
|
|
binaryPath: distribution.entryPath,
|
|
configPath: '',
|
|
workspace: process.cwd(),
|
|
cacheRoot: distribution.cacheRoot,
|
|
trustedBundleHashes: [],
|
|
modelProfile: {
|
|
id: '00000000-0000-4000-8000-000000000099',
|
|
name: 'Local model',
|
|
baseUrl: 'http://127.0.0.1:11434/v1',
|
|
modelName: 'qwen3',
|
|
protocol: 'openai-chat-completions',
|
|
authentication: 'none'
|
|
}
|
|
})
|
|
|
|
await expect(
|
|
adapter.run(
|
|
'search',
|
|
new AbortController().signal,
|
|
async () => 'deny',
|
|
{
|
|
workMode: 'ask',
|
|
knowledgeCapability: {
|
|
endpoint: 'http://127.0.0.1:4567/mcp',
|
|
token: 'main-only-token'
|
|
}
|
|
}
|
|
)
|
|
).rejects.toThrow('未通过宿主兼容性校验')
|
|
await expect(readdir(distribution.cacheRoot)).resolves.not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringMatching(/^model-config-/u)
|
|
])
|
|
)
|
|
})
|
|
|
|
it('removes capability config when cancellation reaches the pre-spawn check', async () => {
|
|
const distribution = await createDistribution()
|
|
const launchHost = vi.fn<ContinueHostLauncher>()
|
|
const adapter = new ContinueHostAdapter({
|
|
binaryPath: distribution.entryPath,
|
|
configPath: '',
|
|
workspace: process.cwd(),
|
|
cacheRoot: distribution.cacheRoot,
|
|
trustedBundleHashes: [distribution.sourceHash],
|
|
launchHost,
|
|
modelProfile: {
|
|
id: '00000000-0000-4000-8000-000000000098',
|
|
name: 'Local model',
|
|
baseUrl: 'http://127.0.0.1:11434/v1',
|
|
modelName: 'qwen3',
|
|
protocol: 'openai-chat-completions',
|
|
authentication: 'none'
|
|
}
|
|
})
|
|
const controller = new AbortController()
|
|
const pending = adapter.run(
|
|
'search',
|
|
controller.signal,
|
|
async () => 'deny',
|
|
{
|
|
workMode: 'ask',
|
|
knowledgeCapability: {
|
|
endpoint: 'http://127.0.0.1:4567/mcp',
|
|
token: 'main-only-token'
|
|
}
|
|
}
|
|
)
|
|
setTimeout(() => controller.abort(new Error('cancelled')), 0)
|
|
|
|
await expect(pending).rejects.toThrow('cancelled')
|
|
expect(launchHost).not.toHaveBeenCalled()
|
|
await expect(readdir(distribution.cacheRoot)).resolves.not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringMatching(/^model-config-/u)
|
|
])
|
|
)
|
|
})
|
|
|
|
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:
|
|
| {
|
|
entryPath: string
|
|
args: string[]
|
|
env: NodeJS.ProcessEnv
|
|
}
|
|
| undefined
|
|
let killed = false
|
|
let generatedConfig = ''
|
|
let generatedConfigPath = ''
|
|
const launchHost: ContinueHostLauncher = (
|
|
entryPath,
|
|
args,
|
|
options
|
|
) => {
|
|
launch = { entryPath, args, env: options.env }
|
|
const configIndex = args.indexOf('--config')
|
|
if (configIndex >= 0) {
|
|
generatedConfigPath = args[configIndex + 1] ?? ''
|
|
generatedConfig = readFileSync(generatedConfigPath, 'utf8')
|
|
}
|
|
return {
|
|
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) => {
|
|
const url = String(input)
|
|
if (url.endsWith('/state')) {
|
|
stateRequests += 1
|
|
return new Response(
|
|
JSON.stringify({
|
|
session: {
|
|
history:
|
|
stateRequests === 1
|
|
? []
|
|
: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'HOST_LAUNCH_OK'
|
|
}
|
|
}
|
|
],
|
|
usage:
|
|
stateRequests === 1
|
|
? {
|
|
promptTokens: 20,
|
|
completionTokens: 10,
|
|
promptTokensDetails: {
|
|
cachedTokens: 5,
|
|
cacheWriteTokens: 2
|
|
}
|
|
}
|
|
: {
|
|
promptTokens: 19,
|
|
completionTokens: 9,
|
|
promptTokensDetails: {
|
|
cachedTokens: 4,
|
|
cacheWriteTokens: 1
|
|
}
|
|
}
|
|
},
|
|
isProcessing: false,
|
|
messageQueueLength: 0,
|
|
pendingPermission: null
|
|
})
|
|
)
|
|
}
|
|
return new Response('{}')
|
|
})
|
|
)
|
|
const adapter = new ContinueHostAdapter({
|
|
binaryPath: distribution.entryPath,
|
|
configPath: '',
|
|
workspace: process.cwd(),
|
|
cacheRoot: distribution.cacheRoot,
|
|
trustedBundleHashes: [distribution.sourceHash],
|
|
launchHost,
|
|
mode: 'chat',
|
|
modelProfile: {
|
|
id: '00000000-0000-4000-8000-000000000011',
|
|
name: '独立模型',
|
|
baseUrl: 'https://model.example',
|
|
modelName: 'private-model',
|
|
protocol: 'anthropic-messages',
|
|
authentication: 'api-key',
|
|
apiKey: 'private-key'
|
|
}
|
|
})
|
|
|
|
await expect(
|
|
adapter.run('hello', new AbortController().signal, async () => 'deny')
|
|
).resolves.toEqual({
|
|
text: 'HOST_LAUNCH_OK',
|
|
usage: {
|
|
provider: 'anthropic',
|
|
model: 'private-model',
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0
|
|
}
|
|
})
|
|
expect(launch?.entryPath).toContain('host-v6')
|
|
expect(launch?.args).toEqual([
|
|
'--config',
|
|
expect.stringContaining('model-config-'),
|
|
'--readonly',
|
|
'serve',
|
|
'--port',
|
|
expect.any(String),
|
|
'--timeout',
|
|
'300'
|
|
])
|
|
expect(launch?.env.GOODBUDDY_CONTINUE_HOST_TOKEN).toEqual(
|
|
expect.any(String)
|
|
)
|
|
expect(launch?.env).toMatchObject({
|
|
CONTINUE_CLI_AUTO_UPDATED: '1',
|
|
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_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({
|
|
models: [
|
|
{
|
|
apiBase: 'https://model.example/v1',
|
|
apiKey: '${{ secrets.ANTHROPIC_API_KEY }}',
|
|
model: 'private-model'
|
|
}
|
|
]
|
|
})
|
|
expect(generatedConfig).not.toContain('private-key')
|
|
expect(launch?.env.ANTHROPIC_API_KEY).toBe('private-key')
|
|
expect(existsSync(generatedConfigPath)).toBe(false)
|
|
})
|
|
|
|
it('injects scoped knowledge into a temporary copy of a JSONC config', async () => {
|
|
const distribution = await createDistribution()
|
|
const configPath = join(
|
|
distribution.cacheRoot,
|
|
'..',
|
|
'continue.jsonc'
|
|
)
|
|
const originalConfig = [
|
|
'{',
|
|
' // User-managed Continue configuration',
|
|
' "name": "Private Continue",',
|
|
' "version": "1.0.0",',
|
|
' "schema": "v1",',
|
|
' "models": [{ "provider": "ollama", "model": "qwen3" }],',
|
|
' "mcpServers": [{ "name": "user-tools", "command": "tool.exe" }],',
|
|
'}'
|
|
].join('\n')
|
|
await writeFile(configPath, originalConfig, 'utf8')
|
|
let generatedConfig = ''
|
|
let generatedConfigPath = ''
|
|
let killed = false
|
|
const launchHost: ContinueHostLauncher = (
|
|
_entryPath,
|
|
args
|
|
) => {
|
|
const configIndex = args.indexOf('--config')
|
|
generatedConfigPath = args[configIndex + 1] ?? ''
|
|
generatedConfig = readFileSync(generatedConfigPath, 'utf8')
|
|
expect(args).toEqual([
|
|
'--config',
|
|
expect.stringContaining('knowledge-config-'),
|
|
'--allow',
|
|
'knowledge_search',
|
|
'--exclude',
|
|
'*',
|
|
'serve',
|
|
'--port',
|
|
expect.any(String),
|
|
'--timeout',
|
|
'300'
|
|
])
|
|
return {
|
|
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: 'CONFIG_KNOWLEDGE_OK'
|
|
}
|
|
}
|
|
]
|
|
},
|
|
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,
|
|
mode: 'agent'
|
|
})
|
|
|
|
await expect(
|
|
adapter.run(
|
|
'search',
|
|
new AbortController().signal,
|
|
async () => 'deny',
|
|
{
|
|
workMode: 'ask',
|
|
knowledgeCapability: {
|
|
endpoint: 'http://127.0.0.1:4567/mcp',
|
|
token: 'main-only-token'
|
|
}
|
|
}
|
|
)
|
|
).resolves.toEqual({ text: 'CONFIG_KNOWLEDGE_OK' })
|
|
expect(JSON.parse(generatedConfig)).toMatchObject({
|
|
name: 'Private Continue',
|
|
models: [{ provider: 'ollama', model: 'qwen3' }],
|
|
mcpServers: [
|
|
{
|
|
name: 'goodbuddy-knowledge',
|
|
type: 'streamable-http',
|
|
url: 'http://127.0.0.1:4567/mcp',
|
|
requestOptions: {
|
|
headers: {
|
|
Authorization: 'Bearer main-only-token'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
})
|
|
expect(generatedConfig).not.toContain('user-tools')
|
|
await expect(readFile(configPath, 'utf8')).resolves.toBe(
|
|
originalConfig
|
|
)
|
|
expect(killed).toBe(true)
|
|
expect(existsSync(generatedConfigPath)).toBe(false)
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
label: 'Chat Completions without authentication',
|
|
protocol: 'openai-chat-completions' as const,
|
|
authentication: 'none' as const,
|
|
useResponsesApi: false
|
|
},
|
|
{
|
|
label: 'Responses with an API key',
|
|
protocol: 'openai-responses' as const,
|
|
authentication: 'api-key' as const,
|
|
useResponsesApi: true
|
|
}
|
|
])(
|
|
'generates an explicit OpenAI config for $label',
|
|
async ({
|
|
protocol,
|
|
authentication,
|
|
useResponsesApi
|
|
}) => {
|
|
inheritProviderCredentials()
|
|
const distribution = await createDistribution()
|
|
let generatedConfig = ''
|
|
let launchedEnvironment: NodeJS.ProcessEnv | undefined
|
|
let launchedArgs: string[] = []
|
|
const launchHost: ContinueHostLauncher = (
|
|
_entryPath,
|
|
args,
|
|
options
|
|
) => {
|
|
launchedArgs = args
|
|
const configIndex = args.indexOf('--config')
|
|
generatedConfig = readFileSync(
|
|
args[configIndex + 1] ?? '',
|
|
'utf8'
|
|
)
|
|
launchedEnvironment = options.env
|
|
return {
|
|
exitCode: null,
|
|
killed: false,
|
|
stderr: null,
|
|
once: () => undefined,
|
|
kill: () => 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: 'OLLAMA_OK'
|
|
}
|
|
}
|
|
],
|
|
usage:
|
|
stateRequests === 1
|
|
? {
|
|
promptTokens: 100,
|
|
completionTokens: 20,
|
|
promptTokensDetails: {
|
|
cachedTokens: 10,
|
|
cacheWriteTokens: 3
|
|
}
|
|
}
|
|
: {
|
|
promptTokens: 131,
|
|
completionTokens: 29,
|
|
promptTokensDetails: {
|
|
cachedTokens: 23,
|
|
cacheWriteTokens: 7
|
|
}
|
|
}
|
|
},
|
|
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-000000000012',
|
|
name: 'Ollama',
|
|
baseUrl: 'http://127.0.0.1:11434/v1',
|
|
modelName: 'qwen3',
|
|
protocol,
|
|
authentication,
|
|
...(authentication === 'api-key'
|
|
? { apiKey: 'private-key' }
|
|
: {})
|
|
}
|
|
})
|
|
|
|
await expect(
|
|
adapter.run(
|
|
'hello',
|
|
new AbortController().signal,
|
|
async () => 'deny',
|
|
{
|
|
workMode: 'ask',
|
|
knowledgeCapability: {
|
|
endpoint: 'http://127.0.0.1:4567/mcp',
|
|
token: 'main-only-token'
|
|
}
|
|
}
|
|
)
|
|
).resolves.toEqual({
|
|
text: 'OLLAMA_OK',
|
|
usage: {
|
|
provider: 'openai',
|
|
model: 'qwen3',
|
|
inputTokens: 31,
|
|
outputTokens: 9,
|
|
cacheReadTokens: 13,
|
|
cacheWriteTokens: 4
|
|
}
|
|
})
|
|
expect(JSON.parse(generatedConfig)).toMatchObject({
|
|
models: [
|
|
{
|
|
provider: 'openai',
|
|
apiBase: 'http://127.0.0.1:11434/v1',
|
|
model: 'qwen3',
|
|
useResponsesApi
|
|
}
|
|
],
|
|
mcpServers: [
|
|
{
|
|
name: 'goodbuddy-knowledge',
|
|
type: 'streamable-http',
|
|
url: 'http://127.0.0.1:4567/mcp',
|
|
requestOptions: {
|
|
headers: {
|
|
Authorization: 'Bearer main-only-token'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
})
|
|
expect(launchedArgs).toEqual(
|
|
expect.arrayContaining([
|
|
'--allow',
|
|
'knowledge_search',
|
|
'--exclude',
|
|
'*'
|
|
])
|
|
)
|
|
expect(launchedArgs).not.toContain('--readonly')
|
|
if (authentication === 'api-key') {
|
|
expect(JSON.parse(generatedConfig)).toMatchObject({
|
|
models: [
|
|
{
|
|
apiKey: '${{ secrets.OPENAI_API_KEY }}'
|
|
}
|
|
]
|
|
})
|
|
expect(launchedEnvironment?.OPENAI_API_KEY).toBe('private-key')
|
|
} else {
|
|
expect(generatedConfig).not.toContain('apiKey')
|
|
expect(launchedEnvironment).not.toHaveProperty(
|
|
'OPENAI_API_KEY'
|
|
)
|
|
}
|
|
for (const name of Object.keys(inheritedProviderCredentials)) {
|
|
const selectedCredential =
|
|
authentication === 'api-key' ? 'OPENAI_API_KEY' : undefined
|
|
if (name !== selectedCredential) {
|
|
expect(launchedEnvironment).not.toHaveProperty(name)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
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'
|
|
},
|
|
toolCallStates: [
|
|
{
|
|
toolCallId: 'call-1',
|
|
toolCall: {
|
|
function: { name: 'Bash' }
|
|
},
|
|
status: 'errored',
|
|
output: [
|
|
{
|
|
content:
|
|
'PowerShell parser failed Authorization: Bearer secret-token'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
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'
|
|
}
|
|
})
|
|
|
|
const run = adapter.run(
|
|
'hello',
|
|
new AbortController().signal,
|
|
async () => 'deny'
|
|
)
|
|
await expect(
|
|
run
|
|
).rejects.toMatchObject({
|
|
message: 'Continue 模型请求失败:Request not allowed',
|
|
tools: [
|
|
{
|
|
callId: 'call-1',
|
|
name: 'Bash',
|
|
state: 'failed',
|
|
error:
|
|
'PowerShell parser failed Authorization: Bearer secret-token'
|
|
}
|
|
]
|
|
})
|
|
expect(killed).toBe(true)
|
|
})
|
|
|
|
it('returns audit metadata for auto-approved agent tools', async () => {
|
|
const distribution = await createDistribution()
|
|
let launchArgs: string[] = []
|
|
const permissionBodies: unknown[] = []
|
|
const streamEvents: unknown[] = []
|
|
const launchHost: ContinueHostLauncher = (
|
|
_entryPath,
|
|
args
|
|
) => {
|
|
launchArgs = args
|
|
return {
|
|
exitCode: null,
|
|
killed: false,
|
|
stderr: null,
|
|
once: () => undefined,
|
|
kill: () => true
|
|
}
|
|
}
|
|
let stateRequests = 0
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
async (
|
|
input: string | URL | Request,
|
|
init?: RequestInit
|
|
) => {
|
|
const url = String(input)
|
|
if (url.endsWith('/permission')) {
|
|
permissionBodies.push(JSON.parse(String(init?.body)))
|
|
return Response.json({})
|
|
}
|
|
if (url.endsWith('/state')) {
|
|
stateRequests += 1
|
|
if (stateRequests === 1) {
|
|
return Response.json({
|
|
session: { history: [] },
|
|
isProcessing: false,
|
|
messageQueueLength: 0,
|
|
pendingPermission: null
|
|
})
|
|
}
|
|
if (stateRequests === 2) {
|
|
return Response.json({
|
|
session: { history: [] },
|
|
isProcessing: true,
|
|
messageQueueLength: 0,
|
|
pendingPermission: {
|
|
toolName: 'Bash',
|
|
toolArgs: { command: 'npm test' },
|
|
requestId: 'permission-1'
|
|
},
|
|
goodbuddyEvents: [
|
|
{ type: 'text', delta: '先检查命令。' },
|
|
{
|
|
type: 'tool',
|
|
callId: 'call-1',
|
|
name: 'Bash',
|
|
state: 'running',
|
|
input:
|
|
'{"command":"npm test","token":"secret-token"}'
|
|
}
|
|
]
|
|
})
|
|
}
|
|
return Response.json({
|
|
session: {
|
|
history: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'TOOLS_OK'
|
|
},
|
|
toolCallStates: [
|
|
{
|
|
toolCallId: 'call-1',
|
|
toolCall: {
|
|
function: { name: 'Bash' }
|
|
},
|
|
status: 'done'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
isProcessing: false,
|
|
messageQueueLength: 0,
|
|
pendingPermission: null,
|
|
goodbuddyEvents: [
|
|
{
|
|
type: 'tool',
|
|
callId: 'call-1',
|
|
name: 'Bash',
|
|
state: 'completed',
|
|
output:
|
|
'Tests passed\nAuthorization: Bearer secret-token'
|
|
},
|
|
{ type: 'text', delta: 'TOOLS_OK' }
|
|
]
|
|
})
|
|
}
|
|
return Response.json({})
|
|
}
|
|
)
|
|
)
|
|
const adapter = new ContinueHostAdapter({
|
|
binaryPath: distribution.entryPath,
|
|
configPath: 'C:\\safe\\continue.yaml',
|
|
workspace: process.cwd(),
|
|
cacheRoot: distribution.cacheRoot,
|
|
trustedBundleHashes: [distribution.sourceHash],
|
|
launchHost,
|
|
mode: 'agent'
|
|
})
|
|
const authorize = vi.fn(async () => 'once' as const)
|
|
|
|
await expect(
|
|
adapter.run(
|
|
'hello',
|
|
new AbortController().signal,
|
|
authorize,
|
|
{
|
|
onEvent: (event) => {
|
|
streamEvents.push(event)
|
|
}
|
|
}
|
|
)
|
|
).resolves.toEqual({
|
|
text: 'TOOLS_OK',
|
|
streamedText: true,
|
|
tools: [
|
|
{
|
|
callId: 'call-1',
|
|
name: 'Bash',
|
|
state: 'completed',
|
|
input:
|
|
'{"command":"npm test","token":"secret-token"}',
|
|
output:
|
|
'Tests passed\nAuthorization: Bearer secret-token'
|
|
}
|
|
]
|
|
})
|
|
expect(streamEvents).toEqual([
|
|
{ type: 'text', delta: '先检查命令。' },
|
|
{
|
|
type: 'tool',
|
|
tool: {
|
|
callId: 'call-1',
|
|
name: 'Bash',
|
|
state: 'running',
|
|
input:
|
|
'{"command":"npm test","token":"secret-token"}'
|
|
}
|
|
},
|
|
{
|
|
type: 'tool',
|
|
tool: {
|
|
callId: 'call-1',
|
|
name: 'Bash',
|
|
state: 'completed',
|
|
output:
|
|
'Tests passed\nAuthorization: Bearer secret-token'
|
|
}
|
|
},
|
|
{ type: 'text', delta: 'TOOLS_OK' }
|
|
])
|
|
expect(launchArgs).not.toContain('--readonly')
|
|
expect(authorize).toHaveBeenCalledWith(
|
|
expect.objectContaining({ toolName: 'Bash' })
|
|
)
|
|
expect(permissionBodies).toEqual([
|
|
{ requestId: 'permission-1', approved: true }
|
|
])
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
label: 'Chat Completions',
|
|
protocol: 'openai-chat-completions' as const,
|
|
expectedPath: '/v1/chat/completions',
|
|
unexpectedPath: '/v1/responses'
|
|
},
|
|
{
|
|
label: 'Responses',
|
|
protocol: 'openai-responses' as const,
|
|
expectedPath: '/v1/responses',
|
|
unexpectedPath: '/v1/chat/completions'
|
|
}
|
|
])(
|
|
'routes a custom-base $label profile to its explicit endpoint in Continue 1.5.47',
|
|
async ({
|
|
protocol,
|
|
expectedPath,
|
|
unexpectedPath
|
|
}) => {
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), 'goodbuddy-continue-responses-')
|
|
)
|
|
temporaryDirectories.push(root)
|
|
const requestPaths: string[] = []
|
|
const server = createServer((request, response) => {
|
|
requestPaths.push(request.url ?? '')
|
|
request.resume()
|
|
response.writeHead(400, {
|
|
'content-type': 'application/json'
|
|
})
|
|
response.end(
|
|
JSON.stringify({
|
|
error: {
|
|
message: 'Intentional local routing probe'
|
|
}
|
|
})
|
|
)
|
|
})
|
|
await new Promise<void>((resolveListen, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(0, '127.0.0.1', () => resolveListen())
|
|
})
|
|
const address = server.address()
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('Failed to bind local routing probe')
|
|
}
|
|
const adapter = new ContinueHostAdapter({
|
|
binaryPath: join(
|
|
process.cwd(),
|
|
'node_modules',
|
|
'@continuedev',
|
|
'cli',
|
|
'dist',
|
|
'cn.js'
|
|
),
|
|
configPath: '',
|
|
workspace: root,
|
|
cacheRoot: join(root, 'cache'),
|
|
modelProfile: {
|
|
id: '00000000-0000-4000-8000-000000000014',
|
|
name: 'Local endpoint probe',
|
|
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
|
modelName: 'probe-model',
|
|
protocol,
|
|
authentication: 'none'
|
|
}
|
|
})
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(
|
|
() => controller.abort(new Error('Routing probe timed out')),
|
|
20_000
|
|
)
|
|
try {
|
|
await adapter
|
|
.run('Reply with OK', controller.signal, async () => 'deny')
|
|
.catch(() => undefined)
|
|
expect(requestPaths).toContain(expectedPath)
|
|
expect(requestPaths).not.toContain(unexpectedPath)
|
|
} finally {
|
|
clearTimeout(timeout)
|
|
adapter.dispose()
|
|
await new Promise((resolveWait) =>
|
|
setTimeout(resolveWait, 500)
|
|
)
|
|
await new Promise<void>((resolveClose, reject) => {
|
|
server.close((error) =>
|
|
error ? reject(error) : resolveClose()
|
|
)
|
|
})
|
|
}
|
|
},
|
|
30_000
|
|
)
|
|
})
|