feat: expand secure runtime and workspace UX
This commit is contained in:
@@ -1,8 +1,67 @@
|
||||
import spawn from 'cross-spawn'
|
||||
import type { WorkspaceChanges } from '../../shared/assistant-contracts'
|
||||
import { basename, extname } from 'node:path'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceChanges,
|
||||
WorkspaceDirectoryListing,
|
||||
WorkspaceFilePreview
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
getCanonicalWorkspace,
|
||||
listBoundedDirectoryEntries,
|
||||
readBoundedUtf8File,
|
||||
resolveExistingWorkspacePath
|
||||
} from '../workspace-file-access'
|
||||
|
||||
const MAX_OUTPUT_BYTES = 512 * 1024
|
||||
const COMMAND_TIMEOUT_MS = 10_000
|
||||
const MAX_DIRECTORY_ENTRIES = 500
|
||||
const MAX_CHANGED_FILES = 2_000
|
||||
const MAX_PREVIEW_BYTES = 256 * 1024
|
||||
const previewExtensions = new Set([
|
||||
'.c',
|
||||
'.cpp',
|
||||
'.cs',
|
||||
'.css',
|
||||
'.csv',
|
||||
'.go',
|
||||
'.h',
|
||||
'.hpp',
|
||||
'.html',
|
||||
'.ini',
|
||||
'.java',
|
||||
'.js',
|
||||
'.json',
|
||||
'.jsx',
|
||||
'.kt',
|
||||
'.kts',
|
||||
'.log',
|
||||
'.md',
|
||||
'.markdown',
|
||||
'.php',
|
||||
'.ps1',
|
||||
'.py',
|
||||
'.rb',
|
||||
'.rs',
|
||||
'.sh',
|
||||
'.sql',
|
||||
'.svelte',
|
||||
'.toml',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.txt',
|
||||
'.vue',
|
||||
'.xml',
|
||||
'.yaml',
|
||||
'.yml'
|
||||
])
|
||||
const previewFileNames = new Set([
|
||||
'dockerfile',
|
||||
'license',
|
||||
'makefile',
|
||||
'notice',
|
||||
'readme'
|
||||
])
|
||||
|
||||
type CommandResult = {
|
||||
code: number | null
|
||||
@@ -63,6 +122,93 @@ function runGit(
|
||||
})
|
||||
}
|
||||
|
||||
function pathSegments(inputPath: string, allowRoot: boolean): string[] {
|
||||
const normalized = inputPath.replaceAll('\\', '/')
|
||||
if (allowRoot && normalized === '') {
|
||||
return []
|
||||
}
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.startsWith('/') ||
|
||||
/^[a-zA-Z]:\//u.test(normalized)
|
||||
) {
|
||||
throw new Error('路径必须是工作区内的相对路径')
|
||||
}
|
||||
const segments = normalized.split('/')
|
||||
if (
|
||||
segments.some(
|
||||
(segment) =>
|
||||
!segment || segment === '.' || segment === '..' || segment.includes('\0')
|
||||
)
|
||||
) {
|
||||
throw new Error('路径必须是工作区内的相对路径')
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
async function resolveWorkspacePath(
|
||||
rootPath: string,
|
||||
inputPath: string,
|
||||
expected: 'file' | 'directory'
|
||||
): Promise<{ canonicalPath: string; path: string }> {
|
||||
const canonicalRoot = await getCanonicalWorkspace(rootPath)
|
||||
const segments = pathSegments(inputPath, expected === 'directory')
|
||||
const canonicalPath = await resolveExistingWorkspacePath(
|
||||
canonicalRoot,
|
||||
segments,
|
||||
expected
|
||||
)
|
||||
return {
|
||||
canonicalPath,
|
||||
path: segments.join('/')
|
||||
}
|
||||
}
|
||||
|
||||
function parseChangedFiles(status: string): {
|
||||
files: WorkspaceChangedFile[]
|
||||
truncated: boolean
|
||||
} {
|
||||
const records = status.split('\0')
|
||||
const files: WorkspaceChangedFile[] = []
|
||||
let index = 0
|
||||
while (index < records.length && files.length < MAX_CHANGED_FILES) {
|
||||
const record = records[index]
|
||||
index += 1
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const statusCode = record.slice(0, 2)
|
||||
const path = record.slice(3)
|
||||
if (!path) {
|
||||
continue
|
||||
}
|
||||
const renamed = statusCode.includes('R') || statusCode.includes('C')
|
||||
const previousPath = renamed ? records[index] : undefined
|
||||
if (renamed) {
|
||||
index += 1
|
||||
}
|
||||
files.push({
|
||||
path,
|
||||
status: statusCode,
|
||||
...(previousPath ? { previousPath } : {})
|
||||
})
|
||||
}
|
||||
return {
|
||||
files,
|
||||
truncated: index < records.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
function formatChangedFiles(files: WorkspaceChangedFile[]): string {
|
||||
return files
|
||||
.map((file) =>
|
||||
file.previousPath
|
||||
? `${file.status} ${file.previousPath} -> ${file.path}`
|
||||
: `${file.status} ${file.path}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export async function getWorkspaceChanges(
|
||||
rootPath: string
|
||||
): Promise<WorkspaceChanges> {
|
||||
@@ -72,13 +218,19 @@ export async function getWorkspaceChanges(
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: false,
|
||||
error: '项目尚未配置工作区目录'
|
||||
}
|
||||
}
|
||||
try {
|
||||
const [status, patch] = await Promise.all([
|
||||
runGit(rootPath, ['status', '--short', '--untracked-files=normal']),
|
||||
runGit(rootPath, [
|
||||
'status',
|
||||
'--porcelain=v1',
|
||||
'-z',
|
||||
'--untracked-files=normal'
|
||||
]),
|
||||
runGit(rootPath, ['diff', '--no-ext-diff', '--no-color', 'HEAD'])
|
||||
])
|
||||
if (status.code !== 0 || patch.code !== 0) {
|
||||
@@ -88,16 +240,20 @@ export async function getWorkspaceChanges(
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: status.truncated || patch.truncated,
|
||||
error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区'
|
||||
}
|
||||
}
|
||||
const changedFiles = parseChangedFiles(status.stdout)
|
||||
return {
|
||||
rootPath,
|
||||
available: true,
|
||||
status: status.stdout,
|
||||
status: formatChangedFiles(changedFiles.files),
|
||||
patch: patch.stdout,
|
||||
truncated: status.truncated || patch.truncated
|
||||
files: changedFiles.files,
|
||||
truncated:
|
||||
status.truncated || patch.truncated || changedFiles.truncated
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -105,9 +261,75 @@ export async function getWorkspaceChanges(
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : '无法读取 Git 工作区'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listWorkspaceDirectory(
|
||||
rootPath: string,
|
||||
inputPath: string
|
||||
): Promise<WorkspaceDirectoryListing> {
|
||||
const directory = await resolveWorkspacePath(
|
||||
rootPath,
|
||||
inputPath,
|
||||
'directory'
|
||||
)
|
||||
const listing = await listBoundedDirectoryEntries(
|
||||
directory.canonicalPath,
|
||||
MAX_DIRECTORY_ENTRIES,
|
||||
(entry) =>
|
||||
entry.name !== '.git' && (entry.isDirectory() || entry.isFile())
|
||||
)
|
||||
const entries = listing.entries.sort((left, right) => {
|
||||
if (left.isDirectory() !== right.isDirectory()) {
|
||||
return left.isDirectory() ? -1 : 1
|
||||
}
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
return {
|
||||
path: directory.path,
|
||||
entries: entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: [directory.path, entry.name].filter(Boolean).join('/'),
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
})),
|
||||
truncated: listing.truncated
|
||||
}
|
||||
}
|
||||
|
||||
export async function readWorkspaceFile(
|
||||
rootPath: string,
|
||||
inputPath: string
|
||||
): Promise<WorkspaceFilePreview> {
|
||||
const file = await resolveWorkspacePath(rootPath, inputPath, 'file')
|
||||
const name = basename(file.canonicalPath)
|
||||
const extension = extname(name).toLowerCase()
|
||||
if (
|
||||
!previewExtensions.has(extension) &&
|
||||
!previewFileNames.has(name.toLowerCase())
|
||||
) {
|
||||
throw new Error('当前文件类型不支持安全预览')
|
||||
}
|
||||
const preview = await readBoundedUtf8File(
|
||||
file.canonicalPath,
|
||||
MAX_PREVIEW_BYTES,
|
||||
'工作区文件超过 256KB 预览限制',
|
||||
'工作区文件不是有效 UTF-8 文本'
|
||||
)
|
||||
return {
|
||||
path: file.path,
|
||||
name,
|
||||
content: preview.content,
|
||||
mimeType:
|
||||
extension === '.md' || extension === '.markdown'
|
||||
? 'text/markdown'
|
||||
: extension === '.json'
|
||||
? 'application/json'
|
||||
: 'text/plain',
|
||||
size: preview.size
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user