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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
698a15ad14
commit
6ef1795b81
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user