feat: expand secure runtime and workspace UX

This commit is contained in:
lofyer
2026-08-04 00:57:08 +08:00
parent 09e9fbf5e2
commit 2910d315f7
56 changed files with 6164 additions and 882 deletions
@@ -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')
})
})
+226 -4
View File
@@ -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
}
}