feat: expand secure runtime and workspace UX
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getWorkspaceChanges } from './workspace-changes-service'
|
||||
import {
|
||||
getWorkspaceChanges,
|
||||
listWorkspaceDirectory,
|
||||
readWorkspaceFile
|
||||
} from './workspace-changes-service'
|
||||
|
||||
const execute = promisify(execFile)
|
||||
const temporaryDirectories: string[] = []
|
||||
@@ -48,6 +52,12 @@ describe('getWorkspaceChanges', () => {
|
||||
})
|
||||
expect(changes.status).toContain('M tracked.txt')
|
||||
expect(changes.status).toContain('?? new.txt')
|
||||
expect(changes.files).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ path: 'tracked.txt', status: ' M' },
|
||||
{ path: 'new.txt', status: '??' }
|
||||
])
|
||||
)
|
||||
expect(changes.patch).toContain('-before')
|
||||
expect(changes.patch).toContain('+after')
|
||||
})
|
||||
@@ -59,6 +69,63 @@ describe('getWorkspaceChanges', () => {
|
||||
const changes = await getWorkspaceChanges(directory)
|
||||
|
||||
expect(changes.available).toBe(false)
|
||||
expect(changes.files).toEqual([])
|
||||
expect(changes.error).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace file browsing', () => {
|
||||
it('lists directories and reads bounded Markdown previews', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
|
||||
temporaryDirectories.push(directory)
|
||||
await mkdir(join(directory, 'docs'))
|
||||
await writeFile(join(directory, 'docs', 'guide.md'), '# 使用说明\n')
|
||||
await writeFile(join(directory, 'notes.txt'), 'hello\n')
|
||||
|
||||
const root = await listWorkspaceDirectory(directory, '')
|
||||
const docs = await listWorkspaceDirectory(directory, 'docs')
|
||||
const preview = await readWorkspaceFile(directory, 'docs/guide.md')
|
||||
|
||||
expect(root.entries).toEqual([
|
||||
{ name: 'docs', path: 'docs', type: 'directory' },
|
||||
{ name: 'notes.txt', path: 'notes.txt', type: 'file' }
|
||||
])
|
||||
expect(docs.entries).toEqual([
|
||||
{
|
||||
name: 'guide.md',
|
||||
path: 'docs/guide.md',
|
||||
type: 'file'
|
||||
}
|
||||
])
|
||||
expect(preview).toMatchObject({
|
||||
path: 'docs/guide.md',
|
||||
name: 'guide.md',
|
||||
content: '# 使用说明\n',
|
||||
mimeType: 'text/markdown'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects traversal, unsupported files, invalid UTF-8, and oversized files', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
|
||||
temporaryDirectories.push(directory)
|
||||
await writeFile(join(directory, 'image.bin'), Buffer.from([0, 1, 2]))
|
||||
await writeFile(join(directory, 'invalid.txt'), Buffer.from([0xff]))
|
||||
await writeFile(
|
||||
join(directory, 'large.txt'),
|
||||
Buffer.alloc(256 * 1024 + 1, 97)
|
||||
)
|
||||
|
||||
await expect(
|
||||
readWorkspaceFile(directory, '../outside.txt')
|
||||
).rejects.toThrow('相对路径')
|
||||
await expect(
|
||||
readWorkspaceFile(directory, 'image.bin')
|
||||
).rejects.toThrow('不支持安全预览')
|
||||
await expect(
|
||||
readWorkspaceFile(directory, 'invalid.txt')
|
||||
).rejects.toThrow('有效 UTF-8')
|
||||
await expect(
|
||||
readWorkspaceFile(directory, 'large.txt')
|
||||
).rejects.toThrow('超过 256KB')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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