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
+364
View File
@@ -0,0 +1,364 @@
import { realpath, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import {
delimiter,
extname,
isAbsolute,
join,
normalize
} from 'node:path'
import spawn from 'cross-spawn'
import { buildRuntimeEnvironment } from './process-environment'
import type {
AgentRuntimeDetection,
RuntimeBinaryDetection
} from '../../shared/contracts'
const VERSION_TIMEOUT_MS = 3_000
const VERSION_OUTPUT_LIMIT = 8 * 1024
export type RuntimeBinaryDiscoveryInput = {
binaryPath: string
bundledPath?: string
binaryNames: readonly string[]
label: string
}
type VersionValidation =
| { valid: true; version?: string }
| { valid: false }
function stripUnsafeCharacters(value: string): string {
let result = ''
let inEscapeSequence = false
for (const character of value) {
const codePoint = character.codePointAt(0) ?? 0
if (inEscapeSequence) {
if (codePoint >= 64 && codePoint <= 126) {
inEscapeSequence = false
}
continue
}
if (codePoint === 27) {
inEscapeSequence = true
} else if (codePoint >= 32 && codePoint !== 127) {
result += character
}
}
return result
}
function safeVersion(output: string): string | undefined {
const firstLine = output
.split(/\r?\n/u)
.map((line) => stripUnsafeCharacters(line).trim())
.find(Boolean)
if (!firstLine) {
return undefined
}
const semanticVersion = firstLine.match(
/\bv?(\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)\b/u
)
return (semanticVersion?.[1] ?? firstLine).slice(0, 160)
}
function 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'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
return
}
child.kill('SIGKILL')
}
function validateVersion(binaryPath: string): Promise<VersionValidation> {
return new Promise((resolve) => {
let settled = false
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let stderrBytes = 0
const child = spawn(binaryPath, ['--version'], {
env: buildRuntimeEnvironment({}),
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
const finish = (result: VersionValidation): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
resolve(result)
}
const exceedLimit = (): void => {
terminate(child)
finish({ valid: false })
}
const timeout = setTimeout(() => {
terminate(child)
finish({ valid: false })
}, VERSION_TIMEOUT_MS)
child.stdout?.on('data', (chunk: Buffer | string) => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
stdoutBytes += value.byteLength
if (stdoutBytes > VERSION_OUTPUT_LIMIT) {
exceedLimit()
return
}
stdout += value.toString('utf8')
})
child.stderr?.on('data', (chunk: Buffer | string) => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
stderrBytes += value.byteLength
if (stderrBytes > VERSION_OUTPUT_LIMIT) {
exceedLimit()
return
}
stderr += value.toString('utf8')
})
child.once('error', () => finish({ valid: false }))
child.once('close', (code) => {
if (code !== 0) {
finish({ valid: false })
return
}
finish({
valid: true,
version: safeVersion(stdout || stderr)
})
})
})
}
async function canonicalFile(filePath: string): Promise<string | undefined> {
if (!isAbsolute(filePath)) {
return undefined
}
try {
const canonicalPath = await realpath(filePath)
const metadata = await stat(canonicalPath)
return metadata.isFile() && isAbsolute(canonicalPath)
? canonicalPath
: undefined
} catch {
return undefined
}
}
function windowsExtensions(): string[] {
const configured = (process.env.PATHEXT ?? '')
.split(';')
.map((value) => value.trim())
.filter((value) => /^\.[A-Za-z0-9]+$/u.test(value))
return [...new Set([...configured, '.COM', '.EXE', '.BAT', '.CMD'])]
}
function executableNames(binaryNames: readonly string[]): string[] {
if (process.platform !== 'win32') {
return [...binaryNames]
}
const extensions = windowsExtensions()
return binaryNames.flatMap((name) =>
extname(name)
? [name]
: extensions.map((extension) => `${name}${extension}`)
)
}
function pathDirectories(): string[] {
const pathValue =
process.env.PATH ?? process.env.Path ?? process.env.path ?? ''
return pathValue
.split(delimiter)
.map((directory) => directory.trim())
.filter((directory) => directory.length > 0 && isAbsolute(directory))
}
function trustedDirectories(): string[] {
if (process.platform === 'win32') {
const directories: string[] = []
const appData = process.env.APPDATA
if (appData && isAbsolute(appData)) {
directories.push(join(appData, 'npm'))
}
return directories
}
const home = homedir()
return [
'/usr/local/bin',
'/usr/bin',
'/bin',
'/opt/homebrew/bin',
'/opt/local/bin',
join(home, '.local', 'bin'),
join(home, 'bin'),
join(home, '.npm-global', 'bin')
]
}
function automaticCandidates(binaryNames: readonly string[]): string[] {
const names = executableNames(binaryNames)
const candidates: string[] = []
const seen = new Set<string>()
for (const directory of [...pathDirectories(), ...trustedDirectories()]) {
for (const name of names) {
const candidate = join(directory, name)
const key =
process.platform === 'win32'
? normalize(candidate).toLowerCase()
: normalize(candidate)
if (!seen.has(key)) {
seen.add(key)
candidates.push(candidate)
}
}
}
return candidates
}
function availableDetection(
label: string,
path: string,
version?: string,
bundled = false
): RuntimeBinaryDetection {
return {
available: true,
path,
version,
detail: `${bundled ? '内置 ' : ''}${label}${
version ? ` ${version}` : ''
} 已就绪`
}
}
export async function detectRuntimeBinary(
input: RuntimeBinaryDiscoveryInput
): Promise<RuntimeBinaryDetection> {
const configuredPath = input.binaryPath.trim()
let configuredPathProblem: 'relative' | 'invalid' | 'validation' | undefined
if (configuredPath) {
if (!isAbsolute(configuredPath)) {
configuredPathProblem = 'relative'
} else {
const canonicalPath = await canonicalFile(configuredPath)
if (!canonicalPath) {
configuredPathProblem = 'invalid'
} else {
const validation = await validateVersion(canonicalPath)
if (validation.valid) {
return availableDetection(
input.label,
canonicalPath,
validation.version
)
}
configuredPathProblem = 'validation'
}
}
}
const bundledPath = input.bundledPath?.trim()
if (bundledPath) {
const canonicalPath = await canonicalFile(bundledPath)
if (canonicalPath) {
const validation = await validateVersion(canonicalPath)
if (validation.valid) {
return availableDetection(
input.label,
canonicalPath,
validation.version,
true
)
}
}
}
let foundAutomaticCandidate = false
for (const candidate of automaticCandidates(input.binaryNames)) {
const canonicalPath = await canonicalFile(candidate)
if (!canonicalPath) {
continue
}
foundAutomaticCandidate = true
const validation = await validateVersion(canonicalPath)
if (validation.valid) {
return availableDetection(
input.label,
canonicalPath,
validation.version
)
}
}
let detail: string
if (foundAutomaticCandidate || configuredPathProblem === 'validation') {
detail = `${input.label} 候选未通过 --version 安全验证`
} else if (configuredPathProblem === 'relative') {
detail = `${input.label} 自定义路径必须为绝对路径,且未自动检测到可用安装`
} else if (configuredPathProblem === 'invalid') {
detail = `${input.label} 自定义路径不是普通文件,且未自动检测到可用安装`
} else {
detail = `未自动检测到 ${input.label},请配置绝对二进制路径`
}
return {
available: false,
detail
}
}
export async function detectAgentRuntimes(input: {
opencodeBinaryPath: string
continueBinaryPath: string
bundledPaths?: {
opencode: string
continue: string
}
}): Promise<AgentRuntimeDetection> {
const [opencode, continueRuntime] = await Promise.all([
detectRuntimeBinary({
binaryPath: input.opencodeBinaryPath,
bundledPath: input.bundledPaths?.opencode,
binaryNames: ['opencode'],
label: 'OpenCode CLI'
}),
detectRuntimeBinary({
binaryPath: input.continueBinaryPath,
bundledPath: input.bundledPaths?.continue,
binaryNames: ['cn'],
label: 'Continue CLI'
})
])
return {
opencode,
continue: continueRuntime
}
}