feat: add persistent desktop assistant workspace

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-31 22:33:03 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 698a15ad14
commit 6ef1795b81
101 changed files with 31866 additions and 1176 deletions
+707
View File
@@ -0,0 +1,707 @@
import spawn from 'cross-spawn'
import { createHash, randomBytes } from 'node:crypto'
import {
copyFile,
mkdir,
readFile,
realpath,
rename,
rm,
stat,
writeFile
} from 'node:fs/promises'
import {
basename,
dirname,
isAbsolute,
join,
resolve
} from 'node:path'
import { z } from 'zod'
import type {
ApprovalDecision,
RuntimeSettings
} from '../../shared/contracts'
import type { RuntimeAuthorizer } from './runtime'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import {
addContinuePermanentPermission,
createContinuePermissionRule
} from './continue-permissions'
import { getAvailableLoopbackPort } from './loopback-port'
import { buildRuntimeEnvironment } from './process-environment'
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
const supportedVersion = '1.5.47'
const supportedBundleHashes = new Set([
'500cf1ae9637ba397fcb5ae0856fdd31b9ad49ba45a32e277477452be196e5d6'
])
const maximumBundleBytes = 32 * 1024 * 1024
const maximumStateBytes = 8 * 1024 * 1024
const utilityBootstrap = [
"import { pathToFileURL } from 'node:url'",
'const entryPath = process.argv[2]',
"if (!entryPath) throw new Error('Missing Continue host entry')",
'process.argv = process.argv.slice(2)',
'await import(pathToFileURL(entryPath).href)',
''
].join('\n')
const stateSchema = z.object({
session: z.object({
history: z.array(z.unknown()).max(5_000)
}),
isProcessing: z.boolean(),
messageQueueLength: z.number().int().min(0),
pendingPermission: z
.object({
toolName: z.string().min(1).max(128),
toolArgs: z.record(z.string(), z.unknown()),
requestId: z.string().min(1).max(256),
toolCallPreview: z.array(z.unknown()).max(100).optional()
})
.nullable()
})
type ContinueHostState = z.infer<typeof stateSchema>
type PreparedHost = {
entryPath: string
version: string
}
export type ContinueHostAdapterOptions = {
binaryPath: string
configPath: string
workspace: string
cacheRoot: string
mode?: RuntimeSettings['continueMode']
trustedBundleHashes?: string[]
launchHost?: ContinueHostLauncher
modelProfile?: ResolvedModelProfile
}
export type ContinueHostChild = {
exitCode: number | null
killed: boolean
pid?: number
stderr?: {
on: (
event: 'data',
listener: (chunk: Buffer | string) => void
) => unknown
} | null
once: (
event: 'error',
listener: (error: Error) => void
) => unknown
kill: (signal?: NodeJS.Signals) => unknown
}
export type ContinueHostLauncher = (
entryPath: string,
args: string[],
options: {
cwd: string
env: NodeJS.ProcessEnv
}
) => ContinueHostChild
function hashContents(value: string | Buffer): string {
return createHash('sha256').update(value).digest('hex')
}
function replaceExactly(
source: string,
marker: string,
replacement: string
): string {
const first = source.indexOf(marker)
if (first < 0 || source.indexOf(marker, first + marker.length) >= 0) {
throw new Error('Continue CLI 版本与宿主适配层不兼容')
}
return `${source.slice(0, first)}${replacement}${source.slice(
first + marker.length
)}`
}
async function isFile(filePath: string): Promise<boolean> {
try {
return (await stat(filePath)).isFile()
} catch {
return false
}
}
async function resolveDistribution(binaryPath: string): Promise<string> {
const canonical = await realpath(binaryPath).catch(() => binaryPath)
const candidates = [
basename(canonical).toLowerCase() === 'cn.js'
? dirname(canonical)
: '',
join(dirname(canonical), 'node_modules', '@continuedev', 'cli', 'dist'),
join(dirname(binaryPath), 'node_modules', '@continuedev', 'cli', 'dist')
].filter(Boolean)
for (const candidate of candidates) {
if (
(await isFile(join(candidate, 'cn.js'))) &&
(await isFile(join(candidate, 'index.js')))
) {
return candidate
}
}
throw new Error(
'当前 Continue 二进制不包含可适配的宿主模块,请使用 npm 安装的 Continue CLI 1.5.47'
)
}
function delay(milliseconds: number, signal: AbortSignal): Promise<void> {
return new Promise((resolveDelay, reject) => {
const finish = (): void => {
signal.removeEventListener('abort', abort)
resolveDelay()
}
const timeout = setTimeout(finish, milliseconds)
const abort = (): void => {
clearTimeout(timeout)
signal.removeEventListener('abort', abort)
reject(signal.reason)
}
signal.addEventListener('abort', abort, { once: true })
})
}
function safeArgumentSummary(
toolArguments: Record<string, unknown>,
preview?: unknown[]
): string {
const previewText = preview
?.flatMap((item) => {
if (!item || typeof item !== 'object') {
return []
}
const value = item as Record<string, unknown>
return typeof value.content === 'string' ? [value.content] : []
})
.join(' ')
.trim()
if (previewText) {
return previewText
.replace(/\bBearer\s+\S+/giu, 'Bearer [REDACTED]')
.replace(
/\b(api[-_ ]?key|token|secret|password|authorization)\b(\s*[:=]\s*|\s+)(["']?)[^\s"',}]+/giu,
'$1$2[REDACTED]'
)
.slice(0, 1_000)
}
const redacted = Object.fromEntries(
Object.entries(toolArguments).map(([key, value]) => [
key,
/token|secret|password|api.?key|authorization/iu.test(key)
? '[REDACTED]'
: value
])
)
return JSON.stringify(redacted).slice(0, 1_000)
}
function extractAssistantText(history: unknown[], startIndex: number): string {
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 record = message as Record<string, unknown>
if (
record.role === 'assistant' &&
typeof record.content === 'string' &&
record.content.trim()
) {
return record.content.trim()
}
}
return ''
}
export class ContinueHostAdapter {
private readonly children = new Set<ContinueHostChild>()
private preparation?: Promise<PreparedHost>
constructor(private readonly options: ContinueHostAdapterOptions) {}
private async prepare(): Promise<PreparedHost> {
if (!isAbsolute(this.options.cacheRoot)) {
throw new Error('Continue 宿主缓存目录必须是绝对路径')
}
const distribution = await resolveDistribution(this.options.binaryPath)
const packagePath = resolve(distribution, '..', 'package.json')
const packageValue = JSON.parse(await readFile(packagePath, 'utf8')) as {
version?: unknown
}
if (packageValue.version !== supportedVersion) {
throw new Error(
`Continue 宿主适配层仅支持 ${supportedVersion},当前版本为 ${
typeof packageValue.version === 'string'
? packageValue.version
: 'unknown'
}`
)
}
const sourceBundlePath = join(distribution, 'index.js')
const sourceBundle = await readFile(sourceBundlePath, 'utf8')
if (Buffer.byteLength(sourceBundle) > maximumBundleBytes) {
throw new Error('Continue CLI bundle 超过安全大小限制')
}
const sourceHash = hashContents(sourceBundle)
const trustedHashes = new Set(
this.options.trustedBundleHashes ?? supportedBundleHashes
)
if (!trustedHashes.has(sourceHash)) {
throw new Error('Continue CLI bundle 未通过宿主兼容性校验')
}
const serveInitializationMarker =
'toolPermissionOverrides:s,headless:!0});let[a,u,l,c]'
const permissionOptionsMarker =
'i={allow:o.allow,ask:o.ask,exclude:o.exclude,isHeadless:e.headless}'
const permissionInitializeMarker =
'E6t.initialize({isHeadless:e.headless},r,n)'
const serverMarker =
'let j=(0,atn.default)();j.use(atn.default.json()),j.get("/state"'
const listenMarker =
'listen(i,async()=>{console.log(Ht.green(`Server started on http://localhost:${i}`))'
const versionCheckMarker =
'async function SCt(e){return n5e||'
let patched = replaceExactly(
sourceBundle,
serveInitializationMarker,
'toolPermissionOverrides:s,headless:!0,interactivePermissions:!0});let[a,u,l,c]'
)
patched = replaceExactly(
patched,
permissionOptionsMarker,
'i={allow:o.allow,ask:o.ask,exclude:o.exclude,isHeadless:e.interactivePermissions?!1:e.headless}'
)
patched = replaceExactly(
patched,
permissionInitializeMarker,
'E6t.initialize({isHeadless:e.interactivePermissions?!1:e.headless},r,n)'
)
patched = replaceExactly(
patched,
serverMarker,
'let j=(0,atn.default)();if(!process.env.GOODBUDDY_CONTINUE_HOST_TOKEN)throw new Error("Missing GoodBuddy host token");j.use((we,Te,ue)=>{we.headers.authorization===`Bearer ${process.env.GOODBUDDY_CONTINUE_HOST_TOKEN}`?ue():Te.status(401).json({error:"Unauthorized"})}),j.use(atn.default.json({limit:"1mb"})),j.get("/state"'
)
patched = replaceExactly(
patched,
listenMarker,
'listen(i,"127.0.0.1",async()=>{console.log(Ht.green(`Server started on http://localhost:${i}`))'
)
patched = replaceExactly(
patched,
versionCheckMarker,
'async function SCt(e){if(process.env.GOODBUDDY_DISABLE_CONTINUE_UPDATES==="1")return null;return n5e||'
)
const patchedHash = hashContents(patched)
const digest = sourceHash.slice(0, 16)
const targetRoot = join(
this.options.cacheRoot,
`host-v2-${supportedVersion}-${digest}`
)
const targetDist = join(targetRoot, 'dist')
const targetBundle = join(targetDist, 'index.js')
const readyMarker = join(targetRoot, '.ready')
if (
(await isFile(readyMarker)) &&
(await isFile(join(targetDist, 'cn.js'))) &&
(await isFile(join(targetDist, 'utility-bootstrap.mjs'))) &&
(await isFile(targetBundle)) &&
hashContents(await readFile(targetBundle)) === patchedHash
) {
return {
entryPath: join(targetDist, 'cn.js'),
version: supportedVersion
}
}
await rm(targetRoot, { recursive: true, force: true })
const stagingRoot = `${targetRoot}.staging-${crypto.randomUUID()}`
const stagingDist = join(stagingRoot, 'dist')
try {
await mkdir(stagingDist, { recursive: true })
await Promise.all([
writeFile(join(stagingDist, 'index.js'), patched, 'utf8'),
copyFile(join(distribution, 'cn.js'), join(stagingDist, 'cn.js')),
copyFile(
join(distribution, 'xhr-sync-worker.js'),
join(stagingDist, 'xhr-sync-worker.js')
),
writeFile(
join(stagingDist, 'utility-bootstrap.mjs'),
utilityBootstrap,
'utf8'
),
copyFile(packagePath, join(stagingRoot, 'package.json'))
])
await writeFile(
join(stagingRoot, '.ready'),
JSON.stringify({ sourceHash, patchedHash }),
'utf8'
)
await mkdir(this.options.cacheRoot, { recursive: true })
await rename(stagingRoot, targetRoot).catch(async (error) => {
if (
!(await isFile(targetBundle)) ||
hashContents(await readFile(targetBundle)) !== patchedHash
) {
throw error
}
})
} finally {
await rm(stagingRoot, { recursive: true, force: true })
}
return {
entryPath: join(targetDist, 'cn.js'),
version: supportedVersion
}
}
getPreparedHost(): Promise<PreparedHost> {
this.preparation ??= this.prepare().catch((error) => {
this.preparation = undefined
throw error
})
return this.preparation
}
private async request(
origin: string,
token: string,
path: string,
init: RequestInit = {}
): Promise<unknown> {
const response = await fetch(`${origin}${path}`, {
...init,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
...init.headers
},
redirect: 'error',
signal: init.signal
})
const contentLength = Number(response.headers.get('content-length') ?? 0)
if (contentLength > maximumStateBytes) {
throw new Error('Continue 宿主响应超过安全大小限制')
}
const body = await response.text()
if (Buffer.byteLength(body) > maximumStateBytes) {
throw new Error('Continue 宿主响应超过安全大小限制')
}
if (!response.ok) {
throw new Error(`Continue 宿主请求失败(HTTP ${response.status})`)
}
return body ? JSON.parse(body) : undefined
}
private async waitForStartup(
child: ContinueHostChild,
getChildFailure: () => Error | undefined,
origin: string,
token: string,
signal: AbortSignal
): Promise<ContinueHostState> {
const expiresAt = Date.now() + 30_000
while (Date.now() < expiresAt) {
signal.throwIfAborted()
const childFailure = getChildFailure()
if (childFailure) {
throw childFailure
}
if (child.exitCode !== null) {
throw new Error('Continue 宿主在启动期间退出')
}
try {
return stateSchema.parse(
await this.request(origin, token, '/state', { signal })
)
} catch {
await delay(150, signal)
}
}
throw new Error('Continue 宿主启动超时')
}
async run(
prompt: string,
signal: AbortSignal,
authorize: RuntimeAuthorizer
): Promise<string> {
signal.throwIfAborted()
let generatedConfigPath: string | undefined
if (this.options.modelProfile) {
if (!this.options.modelProfile.apiKey) {
throw new Error('Continue 独立模型连接尚未配置 API Key')
}
await mkdir(this.options.cacheRoot, { recursive: true })
generatedConfigPath = join(
this.options.cacheRoot,
`model-config-${crypto.randomUUID()}.yaml`
)
await writeFile(
generatedConfigPath,
JSON.stringify({
name: 'GoodBuddy Runtime',
version: '1.0.0',
schema: 'v1',
models: [
{
name: this.options.modelProfile.name,
provider: 'anthropic',
model: this.options.modelProfile.modelName,
apiKey: '${{ secrets.ANTHROPIC_API_KEY }}',
apiBase: createAnthropicApiBaseUrl(
this.options.modelProfile.baseUrl
),
roles: ['chat']
}
]
}),
{ encoding: 'utf8', mode: 0o600, flag: 'wx' }
)
}
const [{ entryPath }, port] = await Promise.all([
this.getPreparedHost(),
getAvailableLoopbackPort()
]).catch(async (error) => {
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
}
throw error
})
const token = randomBytes(32).toString('base64url')
const origin = `http://127.0.0.1:${port}`
const isolatedGlobalDirectory = join(
this.options.cacheRoot,
'isolated-global'
)
await mkdir(isolatedGlobalDirectory, { recursive: true, mode: 0o700 })
const args: string[] = []
const configPath =
generatedConfigPath ?? this.options.configPath.trim()
if (configPath) {
args.push('--config', configPath)
}
if (this.options.mode === 'chat') {
args.push('--readonly')
}
args.push('serve', '--port', String(port), '--timeout', '300')
const environment = buildRuntimeEnvironment({
CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1',
CONTINUE_CLI_AUTO_UPDATED: '1',
CONTINUE_CLI_ENABLE_TELEMETRY: '0',
CONTINUE_METRICS_ENABLED: '0',
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'
})
if (this.options.modelProfile?.apiKey) {
environment.ANTHROPIC_API_KEY = this.options.modelProfile.apiKey
}
let child: ContinueHostChild
try {
child = (
this.options.launchHost ??
((hostEntryPath, hostArgs, hostOptions) =>
spawn(
process.platform === 'win32' ? 'node.exe' : 'node',
[hostEntryPath, ...hostArgs],
{
...hostOptions,
shell: false,
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true
}
))
)(entryPath, args, {
cwd: this.options.workspace,
env: environment
})
} catch (error) {
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
}
throw error
}
this.children.add(child)
let childFailure: Error | undefined
child.once('error', (error) => {
childFailure = new Error('Continue 宿主进程启动失败', {
cause: error
})
})
let stderrBytes = 0
child.stderr?.on('data', (chunk: Buffer | string) => {
stderrBytes += Buffer.byteLength(chunk)
if (stderrBytes > 64 * 1024) {
this.terminate(child)
}
})
const abort = (): void => {
this.terminate(child)
}
signal.addEventListener('abort', abort, { once: true })
try {
const initialState = await this.waitForStartup(
child,
() => childFailure,
origin,
token,
signal
)
const startIndex = initialState.session.history.length
await this.request(origin, token, '/message', {
method: 'POST',
body: JSON.stringify({ message: prompt }),
signal
})
const expiresAt = Date.now() + 10 * 60_000
let handledPermissionId: string | undefined
while (Date.now() < expiresAt) {
signal.throwIfAborted()
if (childFailure) {
throw childFailure
}
if (child.exitCode !== null) {
throw new Error(
`Continue 宿主意外退出(code ${child.exitCode})`
)
}
const state = stateSchema.parse(
await this.request(origin, token, '/state', { signal })
)
const pending = state.pendingPermission
if (pending && pending.requestId !== handledPermissionId) {
handledPermissionId = pending.requestId
let rule: string | undefined
try {
rule = createContinuePermissionRule(
pending.toolName,
pending.toolArgs
)
} catch {
rule = undefined
}
const argumentDigest = createHash('sha256')
.update(JSON.stringify(pending.toolArgs))
.digest('hex')
.slice(0, 16)
const decision: ApprovalDecision = await authorize({
scopeKey: `continue:${
rule ?? `${pending.toolName}:${argumentDigest}`
}`,
title: `Continue 请求调用 ${pending.toolName}`,
description: '仅在你选择允许后,Continue 才会执行此工具调用。',
toolName: pending.toolName,
argumentSummary: safeArgumentSummary(
pending.toolArgs,
pending.toolCallPreview
),
allowPermanent: Boolean(rule)
})
if (decision === 'permanent' && !rule) {
throw new Error('该工具调用无法生成安全的永久权限规则')
}
if (decision === 'permanent' && rule) {
await addContinuePermanentPermission(rule)
}
await this.request(origin, token, '/permission', {
method: 'POST',
body: JSON.stringify({
requestId: pending.requestId,
approved: decision !== 'deny'
}),
signal
})
}
if (
!state.isProcessing &&
state.messageQueueLength === 0 &&
!state.pendingPermission &&
state.session.history.length > startIndex
) {
const text = extractAssistantText(
state.session.history,
startIndex
)
if (!text) {
throw new Error('Continue 宿主未返回最终回复')
}
return text
}
await delay(150, signal)
}
throw new Error('Continue 宿主执行超时')
} finally {
signal.removeEventListener('abort', abort)
try {
const cleanupSignal = AbortSignal.timeout(1_000)
if (signal.aborted) {
await this.request(origin, token, '/pause', {
method: 'POST',
signal: cleanupSignal
}).catch(() => undefined)
}
await this.request(origin, token, '/exit', {
method: 'POST',
signal: cleanupSignal
}).catch(() => undefined)
} finally {
this.terminate(child)
this.children.delete(child)
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
}
}
}
}
private terminate(child: ContinueHostChild): 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()
} else {
child.kill('SIGTERM')
}
}
dispose(): void {
for (const child of this.children) {
this.terminate(child)
}
this.children.clear()
}
}