feat: add managed SSH execution spaces
Add managed SSH Hosts and projects with signed side-by-side GoodBuddy Agent and OpenCode Runtime installation, Ask and Execute workspace access, a Main-owned model bridge, detached Agent lifecycle, durable ACP output recovery, and project activation UI. Use protocol v2 with bounded FIFO frames over SSH instead of custom credit, priority, batching, or model-message chunking. Mismatched Host Agents now upgrade automatically, an exactly verified Node runtime is reused on-host, and local Portable builds can embed strictly verified Agent artifacts. Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
export { LocalWorkspaceAccess } from './local-workspace-access'
|
||||
export { RemoteWorkspaceAccess } from './remote-workspace-access'
|
||||
export {
|
||||
UnsupportedRemoteWorkspaceAccess
|
||||
} from './unsupported-remote-workspace-access'
|
||||
export type {
|
||||
RemoteWorkspaceAccessFactory,
|
||||
RemoteWorkspaceProjectBinding,
|
||||
RemoteWorkspaceTransport,
|
||||
RemoteWorkspaceTransportBinding,
|
||||
RemoteWorkspaceTransportLease
|
||||
} from './remote-workspace-access'
|
||||
export type {
|
||||
DirectoryPage,
|
||||
FilePreview,
|
||||
SearchPage,
|
||||
WorkspaceAccess,
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceChanges,
|
||||
WorkspaceChangesInput,
|
||||
WorkspaceDirectoryEntry,
|
||||
WorkspaceDirectoryInput,
|
||||
WorkspaceEntry,
|
||||
WorkspaceIdentity,
|
||||
WorkspacePathInput,
|
||||
WorkspaceReadInput,
|
||||
WorkspaceSearchInput,
|
||||
WorkspaceSearchMatch,
|
||||
WorkspaceWriteInput,
|
||||
WriteResult
|
||||
} from './workspace-access'
|
||||
@@ -0,0 +1,817 @@
|
||||
import spawn from 'cross-spawn'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { constants as fileSystemConstants } from 'node:fs'
|
||||
import {
|
||||
access,
|
||||
lstat,
|
||||
open,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat
|
||||
} from 'node:fs/promises'
|
||||
import {
|
||||
basename,
|
||||
delimiter,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
resolve
|
||||
} from 'node:path'
|
||||
import {
|
||||
getCanonicalWorkspace,
|
||||
isPathInside,
|
||||
listBoundedDirectoryEntries,
|
||||
readBoundedUtf8File,
|
||||
resolveExistingWorkspacePath
|
||||
} from '../workspace-file-access'
|
||||
import type {
|
||||
DirectoryPage,
|
||||
FilePreview,
|
||||
SearchPage,
|
||||
WorkspaceAccess,
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceChanges,
|
||||
WorkspaceChangesInput,
|
||||
WorkspaceDirectoryInput,
|
||||
WorkspaceEntry,
|
||||
WorkspaceIdentity,
|
||||
WorkspacePathInput,
|
||||
WorkspaceReadInput,
|
||||
WorkspaceSearchInput,
|
||||
WorkspaceWriteInput,
|
||||
WriteResult
|
||||
} from './workspace-access'
|
||||
|
||||
const MAX_GIT_OUTPUT_BYTES = 512 * 1024
|
||||
const GIT_COMMAND_TIMEOUT_MS = 10_000
|
||||
const MAX_GIT_FILTER_DRIVERS = 256
|
||||
const MAX_DIRECTORY_ENTRIES = 500
|
||||
const MAX_CHANGED_FILES = 2_000
|
||||
const DEFAULT_READ_BYTES = 256 * 1024
|
||||
const DEFAULT_WRITE_BYTES = 512 * 1024
|
||||
const DEFAULT_SEARCH_RESULTS = 100
|
||||
const MAX_SEARCH_RESULTS = 1_000
|
||||
const DEFAULT_SEARCH_SCAN_BYTES = 2 * 1024 * 1024
|
||||
const MAX_SEARCH_SCAN_BYTES = 8 * 1024 * 1024
|
||||
const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null'
|
||||
|
||||
const gitEnvironmentAllowlist = [
|
||||
'PATHEXT',
|
||||
'SystemRoot',
|
||||
'WINDIR',
|
||||
'COMSPEC',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'TMPDIR',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'LC_CTYPE'
|
||||
] as const
|
||||
|
||||
const safeGitArguments = [
|
||||
'--no-pager',
|
||||
'--no-optional-locks',
|
||||
'-c',
|
||||
`core.hooksPath=${NULL_DEVICE}`,
|
||||
'-c',
|
||||
'core.fsmonitor=false',
|
||||
'-c',
|
||||
'core.askPass=',
|
||||
'-c',
|
||||
'credential.helper=',
|
||||
'-c',
|
||||
'diff.external=',
|
||||
'-c',
|
||||
'interactive.diffFilter='
|
||||
] as const
|
||||
|
||||
let gitExecutable: Promise<string> | undefined
|
||||
|
||||
type CommandResult = {
|
||||
code: number | null
|
||||
stdout: string
|
||||
stderr: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
type GitConfigOverride = readonly [key: string, value: string]
|
||||
|
||||
/**
|
||||
* Git runs inside user-controlled repositories, so build a fresh environment
|
||||
* rather than filtering Electron Main's environment in place.
|
||||
*/
|
||||
export function buildWorkspaceGitEnvironment(
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
configOverrides: readonly GitConfigOverride[] = []
|
||||
): NodeJS.ProcessEnv {
|
||||
const environment: NodeJS.ProcessEnv = {
|
||||
GIT_ASKPASS: '',
|
||||
GIT_CONFIG_GLOBAL: NULL_DEVICE,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_OPTIONAL_LOCKS: '0',
|
||||
GIT_PAGER: '',
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GCM_INTERACTIVE: 'Never',
|
||||
SSH_ASKPASS: ''
|
||||
}
|
||||
const path = source.PATH ?? source.Path ?? source.path
|
||||
if (path !== undefined) {
|
||||
environment.PATH = path
|
||||
}
|
||||
for (const name of gitEnvironmentAllowlist) {
|
||||
if (source[name] !== undefined) {
|
||||
environment[name] = source[name]
|
||||
}
|
||||
}
|
||||
if (configOverrides.length > 0) {
|
||||
environment.GIT_CONFIG_COUNT = String(configOverrides.length)
|
||||
configOverrides.forEach(([key, value], index) => {
|
||||
environment[`GIT_CONFIG_KEY_${index}`] = key
|
||||
environment[`GIT_CONFIG_VALUE_${index}`] = value
|
||||
})
|
||||
}
|
||||
return environment
|
||||
}
|
||||
|
||||
async function resolveGitExecutable(
|
||||
source: NodeJS.ProcessEnv = process.env
|
||||
): Promise<string> {
|
||||
const path = source.PATH ?? source.Path ?? source.path ?? ''
|
||||
const executableName =
|
||||
process.platform === 'win32' ? 'git.exe' : 'git'
|
||||
for (const rawDirectory of path.split(delimiter)) {
|
||||
const trimmed = rawDirectory.trim()
|
||||
const directory =
|
||||
trimmed.startsWith('"') && trimmed.endsWith('"')
|
||||
? trimmed.slice(1, -1)
|
||||
: trimmed
|
||||
if (!directory || !isAbsolute(directory)) {
|
||||
continue
|
||||
}
|
||||
const candidate = join(directory, executableName)
|
||||
try {
|
||||
await access(candidate, fileSystemConstants.X_OK)
|
||||
const canonicalPath = await realpath(candidate)
|
||||
if (
|
||||
isAbsolute(canonicalPath) &&
|
||||
(await stat(canonicalPath)).isFile()
|
||||
) {
|
||||
return canonicalPath
|
||||
}
|
||||
} catch {
|
||||
// Continue through the explicit PATH candidates.
|
||||
}
|
||||
}
|
||||
throw new Error('无法找到安全的 Git 可执行文件')
|
||||
}
|
||||
|
||||
function getGitExecutable(): Promise<string> {
|
||||
gitExecutable ??= resolveGitExecutable()
|
||||
return gitExecutable
|
||||
}
|
||||
|
||||
function getBoundedInteger(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
maximum: number,
|
||||
message: string
|
||||
): number {
|
||||
const resolved = value ?? fallback
|
||||
if (
|
||||
!Number.isSafeInteger(resolved) ||
|
||||
resolved < 1 ||
|
||||
resolved > maximum
|
||||
) {
|
||||
throw new Error(message)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function normalizeRelativePath(
|
||||
inputPath: string,
|
||||
allowRoot: boolean
|
||||
): { path: string; segments: string[] } {
|
||||
const normalized = inputPath.replaceAll('\\', '/')
|
||||
if (allowRoot && (normalized === '' || normalized === '.')) {
|
||||
return { path: '', segments: [] }
|
||||
}
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.startsWith('/') ||
|
||||
/^[a-zA-Z]:\//u.test(normalized) ||
|
||||
isAbsolute(normalized) ||
|
||||
normalized.includes('\0')
|
||||
) {
|
||||
throw new Error('路径必须是工作区内的相对路径')
|
||||
}
|
||||
const segments = normalized.split('/')
|
||||
if (
|
||||
segments.some(
|
||||
(segment) =>
|
||||
!segment || segment === '.' || segment === '..'
|
||||
)
|
||||
) {
|
||||
throw new Error('路径必须是工作区内的相对路径,不能超出工作区')
|
||||
}
|
||||
return {
|
||||
path: segments.join('/'),
|
||||
segments
|
||||
}
|
||||
}
|
||||
|
||||
async function runGit(
|
||||
rootPath: string,
|
||||
args: string[],
|
||||
signal?: AbortSignal,
|
||||
configOverrides: readonly GitConfigOverride[] = []
|
||||
): Promise<CommandResult> {
|
||||
const executablePath = await getGitExecutable()
|
||||
return new Promise((resolveResult, reject) => {
|
||||
signal?.throwIfAborted()
|
||||
const child = spawn(executablePath, args, {
|
||||
cwd: rootPath,
|
||||
env: buildWorkspaceGitEnvironment(
|
||||
process.env,
|
||||
configOverrides
|
||||
),
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
const stdout: Buffer[] = []
|
||||
const stderr: Buffer[] = []
|
||||
let bytes = 0
|
||||
let truncated = false
|
||||
let settled = false
|
||||
const capture = (target: Buffer[], chunk: Buffer | string): void => {
|
||||
const buffer = Buffer.from(chunk)
|
||||
const remaining = MAX_GIT_OUTPUT_BYTES - bytes
|
||||
if (remaining <= 0) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
target.push(buffer.subarray(0, remaining))
|
||||
bytes += Math.min(buffer.byteLength, remaining)
|
||||
truncated ||= buffer.byteLength > remaining
|
||||
}
|
||||
const finish = (
|
||||
callback: () => void
|
||||
): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
signal?.removeEventListener('abort', abort)
|
||||
callback()
|
||||
}
|
||||
const abort = (): void => {
|
||||
child.kill()
|
||||
finish(() => reject(signal?.reason))
|
||||
}
|
||||
child.stdout?.on('data', (chunk: Buffer | string) =>
|
||||
capture(stdout, chunk)
|
||||
)
|
||||
child.stderr?.on('data', (chunk: Buffer | string) =>
|
||||
capture(stderr, chunk)
|
||||
)
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill()
|
||||
finish(() => reject(new Error('读取文件更改超时')))
|
||||
}, GIT_COMMAND_TIMEOUT_MS)
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
child.once('error', (error) => {
|
||||
finish(() => reject(error))
|
||||
})
|
||||
child.once('close', (code) => {
|
||||
finish(() =>
|
||||
resolveResult({
|
||||
code,
|
||||
stdout: Buffer.concat(stdout).toString('utf8'),
|
||||
stderr: Buffer.concat(stderr).toString('utf8'),
|
||||
truncated
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function withSafeGitArguments(args: string[]): string[] {
|
||||
return [...safeGitArguments, ...args]
|
||||
}
|
||||
|
||||
async function getFilterOverrides(
|
||||
rootPath: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<GitConfigOverride[]> {
|
||||
const result = await runGit(
|
||||
rootPath,
|
||||
withSafeGitArguments([
|
||||
'config',
|
||||
'--local',
|
||||
'--includes',
|
||||
'--name-only',
|
||||
'-z',
|
||||
'--get-regexp',
|
||||
'^filter\\..*\\.(clean|smudge|process|required)$'
|
||||
]),
|
||||
signal
|
||||
)
|
||||
if (result.truncated) {
|
||||
throw new Error('Git 过滤器配置超过安全限制')
|
||||
}
|
||||
// `git config --get-regexp` returns 1 when no names matched.
|
||||
if (result.code !== 0 && result.code !== 1) {
|
||||
throw new Error(
|
||||
result.stderr.trim().slice(0, 2_000) ||
|
||||
'无法安全读取 Git 过滤器配置'
|
||||
)
|
||||
}
|
||||
|
||||
const drivers = new Set<string>()
|
||||
for (const name of result.stdout.split('\0')) {
|
||||
const match = /^(filter\..*)\.(?:clean|smudge|process|required)$/iu.exec(
|
||||
name
|
||||
)
|
||||
if (match?.[1]) {
|
||||
drivers.add(match[1])
|
||||
if (drivers.size > MAX_GIT_FILTER_DRIVERS) {
|
||||
throw new Error('Git 过滤器配置超过安全限制')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...drivers].flatMap(
|
||||
(driver): GitConfigOverride[] => [
|
||||
[`${driver}.clean`, ''],
|
||||
[`${driver}.smudge`, ''],
|
||||
[`${driver}.process`, ''],
|
||||
[`${driver}.required`, 'false']
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
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 class LocalWorkspaceAccess implements WorkspaceAccess {
|
||||
private canonicalWorkspace?: Promise<string>
|
||||
|
||||
constructor(private readonly rootPath: string) {}
|
||||
|
||||
private getWorkspace(): Promise<string> {
|
||||
this.canonicalWorkspace ??= getCanonicalWorkspace(
|
||||
this.rootPath,
|
||||
'项目工作区不是目录'
|
||||
)
|
||||
return this.canonicalWorkspace
|
||||
}
|
||||
|
||||
private async resolveExisting(
|
||||
inputPath: string,
|
||||
expected: 'file' | 'directory'
|
||||
): Promise<{ canonicalPath: string; path: string }> {
|
||||
const root = await this.getWorkspace()
|
||||
const normalized = normalizeRelativePath(
|
||||
inputPath,
|
||||
expected === 'directory'
|
||||
)
|
||||
return {
|
||||
canonicalPath: await resolveExistingWorkspacePath(
|
||||
root,
|
||||
normalized.segments,
|
||||
expected
|
||||
),
|
||||
path: normalized.path
|
||||
}
|
||||
}
|
||||
|
||||
async resolveEntryPath(
|
||||
inputPath: string,
|
||||
expected: 'file' | 'directory'
|
||||
): Promise<string> {
|
||||
return (await this.resolveExisting(inputPath, expected)).canonicalPath
|
||||
}
|
||||
|
||||
private async resolveWritable(inputPath: string): Promise<{
|
||||
canonicalPath: string
|
||||
path: string
|
||||
}> {
|
||||
const root = await this.getWorkspace()
|
||||
const normalized = normalizeRelativePath(inputPath, false)
|
||||
const candidate = resolve(root, ...normalized.segments)
|
||||
if (!isPathInside(root, candidate) || candidate === root) {
|
||||
throw new Error('工具路径不能超出工作区')
|
||||
}
|
||||
const canonicalParent = await realpath(dirname(candidate))
|
||||
if (!isPathInside(root, canonicalParent)) {
|
||||
throw new Error('工具路径不能通过符号链接超出工作区')
|
||||
}
|
||||
const existing = await lstat(candidate).catch((error: unknown) => {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
})
|
||||
if (existing?.isSymbolicLink()) {
|
||||
throw new Error('工作区写入工具拒绝符号链接')
|
||||
}
|
||||
if (existing && !existing.isFile()) {
|
||||
throw new Error('工作区写入目标不是普通文件')
|
||||
}
|
||||
return { canonicalPath: candidate, path: normalized.path }
|
||||
}
|
||||
|
||||
async getIdentity(): Promise<WorkspaceIdentity> {
|
||||
const canonicalRoot = await this.getWorkspace()
|
||||
const metadata = await stat(canonicalRoot)
|
||||
return {
|
||||
kind: 'local',
|
||||
id: `local:${metadata.dev}:${metadata.ino}:${canonicalRoot}`,
|
||||
canonicalDisplayPath: canonicalRoot,
|
||||
access: 'read-write'
|
||||
}
|
||||
}
|
||||
|
||||
async listDirectory(
|
||||
input: WorkspaceDirectoryInput
|
||||
): Promise<DirectoryPage> {
|
||||
input.signal?.throwIfAborted()
|
||||
const maximumEntries = getBoundedInteger(
|
||||
input.maximumEntries,
|
||||
MAX_DIRECTORY_ENTRIES,
|
||||
MAX_DIRECTORY_ENTRIES,
|
||||
'工作区目录条目上限无效'
|
||||
)
|
||||
const directory = await this.resolveExisting(input.path, 'directory')
|
||||
const listing = await listBoundedDirectoryEntries(
|
||||
directory.canonicalPath,
|
||||
maximumEntries,
|
||||
(entry) =>
|
||||
(input.includeGit === true || entry.name !== '.git') &&
|
||||
(input.includeOther === true ||
|
||||
entry.isDirectory() ||
|
||||
entry.isFile())
|
||||
)
|
||||
input.signal?.throwIfAborted()
|
||||
const entries = listing.entries.sort((left, right) => {
|
||||
if (
|
||||
input.includeOther !== true &&
|
||||
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'
|
||||
: entry.isFile()
|
||||
? 'file'
|
||||
: 'other'
|
||||
})),
|
||||
truncated: listing.truncated
|
||||
}
|
||||
}
|
||||
|
||||
async stat(input: WorkspacePathInput): Promise<WorkspaceEntry> {
|
||||
input.signal?.throwIfAborted()
|
||||
const root = await this.getWorkspace()
|
||||
const normalized = normalizeRelativePath(input.path, true)
|
||||
const candidate = resolve(root, ...normalized.segments)
|
||||
if (!isPathInside(root, candidate)) {
|
||||
throw new Error('文件路径不能超出项目工作区')
|
||||
}
|
||||
const canonicalPath = await realpath(candidate)
|
||||
if (!isPathInside(root, canonicalPath)) {
|
||||
throw new Error('文件路径不能通过符号链接超出项目工作区')
|
||||
}
|
||||
const metadata = await stat(canonicalPath)
|
||||
input.signal?.throwIfAborted()
|
||||
return {
|
||||
name: basename(canonicalPath),
|
||||
path: normalized.path,
|
||||
type: metadata.isDirectory()
|
||||
? 'directory'
|
||||
: metadata.isFile()
|
||||
? 'file'
|
||||
: 'other',
|
||||
size: metadata.size,
|
||||
modifiedAt: metadata.mtime.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async readText(input: WorkspaceReadInput): Promise<FilePreview> {
|
||||
input.signal?.throwIfAborted()
|
||||
const maximumBytes = getBoundedInteger(
|
||||
input.maximumBytes,
|
||||
DEFAULT_READ_BYTES,
|
||||
DEFAULT_WRITE_BYTES,
|
||||
'工作区读取上限无效'
|
||||
)
|
||||
const file = await this.resolveExisting(input.path, 'file')
|
||||
const preview = await readBoundedUtf8File(
|
||||
file.canonicalPath,
|
||||
maximumBytes,
|
||||
input.tooLargeMessage ?? '工作区文本文件超过安全限制',
|
||||
input.invalidUtf8Message ?? '工作区读取目标不是有效 UTF-8 文本'
|
||||
)
|
||||
input.signal?.throwIfAborted()
|
||||
return {
|
||||
path: file.path,
|
||||
name: basename(file.canonicalPath),
|
||||
content: preview.content,
|
||||
size: preview.size
|
||||
}
|
||||
}
|
||||
|
||||
async writeTextAtomic(
|
||||
input: WorkspaceWriteInput
|
||||
): Promise<WriteResult> {
|
||||
input.signal?.throwIfAborted()
|
||||
const maximumBytes = getBoundedInteger(
|
||||
input.maximumBytes,
|
||||
DEFAULT_WRITE_BYTES,
|
||||
DEFAULT_WRITE_BYTES,
|
||||
'工作区写入上限无效'
|
||||
)
|
||||
const bytesWritten = Buffer.byteLength(input.content)
|
||||
if (bytesWritten > maximumBytes) {
|
||||
throw new Error('写入内容超过 512KB 安全限制')
|
||||
}
|
||||
const file = await this.resolveWritable(input.path)
|
||||
const temporaryPath = `${file.canonicalPath}.${randomUUID()}.tmp`
|
||||
const handle = await open(temporaryPath, 'wx', 0o600)
|
||||
try {
|
||||
try {
|
||||
await handle.writeFile(input.content, 'utf8')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
input.signal?.throwIfAborted()
|
||||
await rename(temporaryPath, file.canonicalPath)
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined)
|
||||
if (input.signal?.aborted) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('无法安全写入工作区文件', { cause: error })
|
||||
}
|
||||
return { path: file.path, bytesWritten }
|
||||
}
|
||||
|
||||
async search(input: WorkspaceSearchInput): Promise<SearchPage> {
|
||||
input.signal?.throwIfAborted()
|
||||
if (
|
||||
!input.query ||
|
||||
input.query.includes('\0') ||
|
||||
Buffer.byteLength(input.query) > 4_096
|
||||
) {
|
||||
throw new Error('工作区搜索词无效')
|
||||
}
|
||||
const maximumResults = getBoundedInteger(
|
||||
input.maximumResults,
|
||||
DEFAULT_SEARCH_RESULTS,
|
||||
MAX_SEARCH_RESULTS,
|
||||
'工作区搜索结果上限无效'
|
||||
)
|
||||
const maximumFileBytes = getBoundedInteger(
|
||||
input.maximumFileBytes,
|
||||
DEFAULT_READ_BYTES,
|
||||
DEFAULT_WRITE_BYTES,
|
||||
'工作区搜索文件上限无效'
|
||||
)
|
||||
const maximumScannedBytes = getBoundedInteger(
|
||||
input.maximumScannedBytes,
|
||||
DEFAULT_SEARCH_SCAN_BYTES,
|
||||
MAX_SEARCH_SCAN_BYTES,
|
||||
'工作区搜索扫描上限无效'
|
||||
)
|
||||
const queue = [input.path ?? '']
|
||||
const matches: SearchPage['matches'] = []
|
||||
let scannedBytes = 0
|
||||
let truncated = false
|
||||
while (queue.length > 0 && !truncated) {
|
||||
input.signal?.throwIfAborted()
|
||||
const currentPath = queue.shift()!
|
||||
const listing = await this.listDirectory({
|
||||
path: currentPath,
|
||||
maximumEntries: MAX_DIRECTORY_ENTRIES,
|
||||
signal: input.signal
|
||||
})
|
||||
truncated ||= listing.truncated
|
||||
for (const entry of listing.entries) {
|
||||
if (entry.type === 'directory') {
|
||||
queue.push(entry.path)
|
||||
continue
|
||||
}
|
||||
if (entry.type !== 'file') {
|
||||
continue
|
||||
}
|
||||
const metadata = await this.stat({
|
||||
path: entry.path,
|
||||
signal: input.signal
|
||||
})
|
||||
if (
|
||||
metadata.size > maximumFileBytes ||
|
||||
scannedBytes + metadata.size > maximumScannedBytes
|
||||
) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
scannedBytes += metadata.size
|
||||
let content: string
|
||||
try {
|
||||
content = (
|
||||
await this.readText({
|
||||
path: entry.path,
|
||||
maximumBytes: maximumFileBytes,
|
||||
signal: input.signal
|
||||
})
|
||||
).content
|
||||
} catch (error) {
|
||||
if (input.signal?.aborted) {
|
||||
throw error
|
||||
}
|
||||
continue
|
||||
}
|
||||
const lines = content.split(/\r?\n/u)
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index]!
|
||||
const column = line.indexOf(input.query)
|
||||
if (column < 0) {
|
||||
continue
|
||||
}
|
||||
matches.push({
|
||||
path: entry.path,
|
||||
line: index + 1,
|
||||
column: column + 1,
|
||||
preview: line.slice(0, 1_000)
|
||||
})
|
||||
if (matches.length >= maximumResults) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (truncated) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return { matches, truncated }
|
||||
}
|
||||
|
||||
async getChanges(
|
||||
input: WorkspaceChangesInput
|
||||
): Promise<WorkspaceChanges> {
|
||||
input.signal?.throwIfAborted()
|
||||
if (!this.rootPath.trim()) {
|
||||
return {
|
||||
rootPath: this.rootPath,
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: false,
|
||||
error: '项目尚未配置工作区目录'
|
||||
}
|
||||
}
|
||||
const gitMetadata = await stat(join(this.rootPath, '.git')).catch(
|
||||
() => undefined
|
||||
)
|
||||
if (!gitMetadata) {
|
||||
return {
|
||||
rootPath: this.rootPath,
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
try {
|
||||
const canonicalRoot = await this.getWorkspace()
|
||||
const filterOverrides = await getFilterOverrides(
|
||||
canonicalRoot,
|
||||
input.signal
|
||||
)
|
||||
const [statusResult, patchResult] = await Promise.all([
|
||||
runGit(
|
||||
canonicalRoot,
|
||||
withSafeGitArguments([
|
||||
'status',
|
||||
'--porcelain=v1',
|
||||
'-z',
|
||||
'--untracked-files=normal'
|
||||
]),
|
||||
input.signal,
|
||||
filterOverrides
|
||||
),
|
||||
runGit(
|
||||
canonicalRoot,
|
||||
withSafeGitArguments([
|
||||
'diff',
|
||||
'--no-ext-diff',
|
||||
'--no-textconv',
|
||||
'--no-color',
|
||||
'HEAD'
|
||||
]),
|
||||
input.signal,
|
||||
filterOverrides
|
||||
)
|
||||
])
|
||||
if (statusResult.code !== 0 || patchResult.code !== 0) {
|
||||
const detail = statusResult.stderr || patchResult.stderr
|
||||
return {
|
||||
rootPath: this.rootPath,
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: statusResult.truncated || patchResult.truncated,
|
||||
error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区'
|
||||
}
|
||||
}
|
||||
const changedFiles = parseChangedFiles(statusResult.stdout)
|
||||
return {
|
||||
rootPath: this.rootPath,
|
||||
available: true,
|
||||
status: formatChangedFiles(changedFiles.files),
|
||||
patch: patchResult.stdout,
|
||||
files: changedFiles.files,
|
||||
truncated:
|
||||
statusResult.truncated ||
|
||||
patchResult.truncated ||
|
||||
changedFiles.truncated
|
||||
}
|
||||
} catch (error) {
|
||||
if (input.signal?.aborted) {
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
rootPath: this.rootPath,
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : '无法读取 Git 工作区'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
RemoteWorkspaceHandle,
|
||||
RemoteWorkspaceListRequest
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import {
|
||||
RemoteWorkspaceAccess,
|
||||
type RemoteWorkspaceProjectBinding,
|
||||
type RemoteWorkspaceTransport,
|
||||
type RemoteWorkspaceTransportLease
|
||||
} from './remote-workspace-access'
|
||||
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
const binding: RemoteWorkspaceProjectBinding = {
|
||||
hostId: 'host-1',
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'builder',
|
||||
remoteRootPath: '/srv/project',
|
||||
workspaceIdentity: 'identity-1',
|
||||
agentInstallationId: 'installation-1',
|
||||
agentBinaryDigest: digest,
|
||||
agentVersion: '0.11.0',
|
||||
agentArchitecture: 'x64',
|
||||
agentProtocolMajor: 1
|
||||
}
|
||||
const handle: RemoteWorkspaceHandle = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceIdentity: binding.workspaceIdentity,
|
||||
canonicalDisplayPath: binding.remoteRootPath,
|
||||
access: 'read-only',
|
||||
git: 'available',
|
||||
capabilities: [
|
||||
'list',
|
||||
'stat',
|
||||
'read-text',
|
||||
'search',
|
||||
'git-status',
|
||||
'git-diff'
|
||||
],
|
||||
generation: 5
|
||||
}
|
||||
|
||||
function createLease(
|
||||
overrides: Partial<RemoteWorkspaceTransportLease> = {}
|
||||
): RemoteWorkspaceTransportLease {
|
||||
return {
|
||||
binding: {
|
||||
hostId: binding.hostId,
|
||||
hostRevision: binding.hostRevision,
|
||||
hostKeyGeneration: binding.hostKeyGeneration,
|
||||
remoteUsername: binding.remoteUsername,
|
||||
agentInstallationId: binding.agentInstallationId,
|
||||
agentBinaryDigest: binding.agentBinaryDigest,
|
||||
agentVersion: binding.agentVersion,
|
||||
agentArchitecture: binding.agentArchitecture,
|
||||
agentProtocolMajor: binding.agentProtocolMajor,
|
||||
capabilityGeneration: 6
|
||||
},
|
||||
validateWorkspace: vi.fn(async () => ({
|
||||
handle,
|
||||
validatedAt: '2026-08-21T00:00:00.000Z'
|
||||
})),
|
||||
openWorkspace: vi.fn(async () => handle),
|
||||
resumeWorkspace: vi.fn(async () => ({
|
||||
resumed: true,
|
||||
handle
|
||||
})),
|
||||
closeWorkspace: vi.fn(async (request) => ({
|
||||
...request,
|
||||
closed: true as const
|
||||
})),
|
||||
listWorkspace: vi.fn(async () => ({
|
||||
entries: [],
|
||||
nextCursor: undefined
|
||||
})),
|
||||
statWorkspace: vi.fn(async (request) => ({
|
||||
relativePath: request.relativePath,
|
||||
name: request.relativePath.split('/').at(-1) ?? 'project',
|
||||
kind: 'file' as const,
|
||||
byteLength: 5,
|
||||
modifiedAt: '2026-08-21T00:00:00.000Z',
|
||||
digest,
|
||||
executable: false
|
||||
})),
|
||||
readWorkspaceText: vi.fn(async (request) => ({
|
||||
relativePath: request.relativePath,
|
||||
content: 'hello',
|
||||
offsetBytes: request.offsetBytes,
|
||||
bytesRead: 5,
|
||||
totalBytes: 5,
|
||||
digest,
|
||||
truncated: false
|
||||
})),
|
||||
searchWorkspace: vi.fn(async () => ({
|
||||
matches: [],
|
||||
truncated: false
|
||||
})),
|
||||
getGitStatus: vi.fn(async () => ({
|
||||
repositoryIdentity: 'repository-1',
|
||||
branch: 'main',
|
||||
entries: [],
|
||||
truncated: false
|
||||
})),
|
||||
getGitDiff: vi.fn(async () => ({
|
||||
repositoryIdentity: 'repository-1',
|
||||
patch: '',
|
||||
byteLength: 0,
|
||||
truncated: false
|
||||
})),
|
||||
release: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createAccess(lease: RemoteWorkspaceTransportLease): {
|
||||
access: RemoteWorkspaceAccess
|
||||
transport: RemoteWorkspaceTransport
|
||||
} {
|
||||
const transport: RemoteWorkspaceTransport = {
|
||||
acquireLease: vi.fn(async () => lease)
|
||||
}
|
||||
return {
|
||||
access: new RemoteWorkspaceAccess(binding, transport),
|
||||
transport
|
||||
}
|
||||
}
|
||||
|
||||
describe('RemoteWorkspaceAccess', () => {
|
||||
it('lazily validates once and maps bounded workspace methods', async () => {
|
||||
const lease = createLease({
|
||||
listWorkspace: vi.fn(async () => ({
|
||||
entries: [
|
||||
{
|
||||
relativePath: 'src',
|
||||
name: 'src',
|
||||
kind: 'directory' as const,
|
||||
byteLength: 0,
|
||||
modifiedAt: '2026-08-21T00:00:00.000Z',
|
||||
executable: true
|
||||
}
|
||||
],
|
||||
nextCursor: '2'
|
||||
})),
|
||||
searchWorkspace: vi.fn(async () => ({
|
||||
matches: [
|
||||
{
|
||||
relativePath: 'src/main.ts',
|
||||
line: 2,
|
||||
column: 3,
|
||||
snippet: ' target'
|
||||
}
|
||||
],
|
||||
truncated: false
|
||||
}))
|
||||
})
|
||||
const { access, transport } = createAccess(lease)
|
||||
|
||||
await expect(access.getIdentity()).resolves.toEqual({
|
||||
kind: 'remote',
|
||||
id: 'host-1:identity-1',
|
||||
canonicalDisplayPath: '/srv/project',
|
||||
access: 'read-only'
|
||||
})
|
||||
await expect(
|
||||
access.listDirectory({ path: '', maximumEntries: 10 })
|
||||
).resolves.toEqual({
|
||||
path: '',
|
||||
entries: [{ name: 'src', path: 'src', type: 'directory' }],
|
||||
truncated: true
|
||||
})
|
||||
await expect(access.stat({ path: 'README.md' })).resolves.toEqual({
|
||||
name: 'README.md',
|
||||
path: 'README.md',
|
||||
type: 'file',
|
||||
size: 5,
|
||||
modifiedAt: '2026-08-21T00:00:00.000Z'
|
||||
})
|
||||
await expect(
|
||||
access.readText({ path: 'README.md', maximumBytes: 10 })
|
||||
).resolves.toEqual({
|
||||
path: 'README.md',
|
||||
name: 'README.md',
|
||||
content: 'hello',
|
||||
size: 5
|
||||
})
|
||||
await expect(
|
||||
access.search({
|
||||
query: 'target',
|
||||
path: 'src',
|
||||
maximumResults: 10
|
||||
})
|
||||
).resolves.toEqual({
|
||||
matches: [
|
||||
{
|
||||
path: 'src/main.ts',
|
||||
line: 2,
|
||||
column: 3,
|
||||
preview: ' target'
|
||||
}
|
||||
],
|
||||
truncated: false
|
||||
})
|
||||
|
||||
expect(transport.acquireLease).toHaveBeenCalledOnce()
|
||||
expect(lease.validateWorkspace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteRootPath: '/srv/project',
|
||||
requestedAccess: 'read-only'
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
expect(lease.listWorkspace).toHaveBeenCalledWith(
|
||||
{
|
||||
workspaceId: 'workspace-1',
|
||||
generation: 5,
|
||||
relativePath: '',
|
||||
limit: 10
|
||||
} satisfies RemoteWorkspaceListRequest,
|
||||
undefined
|
||||
)
|
||||
expect(lease.searchWorkspace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'workspace-1',
|
||||
generation: 5,
|
||||
query: 'target',
|
||||
pathPrefix: 'src',
|
||||
limit: 10
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('lists a readable non-Git workspace without requiring Git capabilities', async () => {
|
||||
const nonGitHandle: RemoteWorkspaceHandle = {
|
||||
...handle,
|
||||
git: 'not-a-repository',
|
||||
capabilities: ['list', 'stat', 'read-text', 'search']
|
||||
}
|
||||
const getGitStatus = vi.fn()
|
||||
const getGitDiff = vi.fn()
|
||||
const lease = createLease({
|
||||
validateWorkspace: vi.fn(async () => ({
|
||||
handle: nonGitHandle,
|
||||
validatedAt: '2026-08-21T00:00:00.000Z'
|
||||
})),
|
||||
getGitStatus,
|
||||
getGitDiff
|
||||
})
|
||||
const { access } = createAccess(lease)
|
||||
|
||||
await expect(
|
||||
access.listDirectory({ path: '' })
|
||||
).resolves.toEqual({
|
||||
path: '',
|
||||
entries: [],
|
||||
truncated: false
|
||||
})
|
||||
expect(lease.validateWorkspace).toHaveBeenCalledWith(
|
||||
{
|
||||
remoteRootPath: binding.remoteRootPath,
|
||||
requestedAccess: 'read-only',
|
||||
requiredCapabilities: [
|
||||
'list',
|
||||
'stat',
|
||||
'read-text',
|
||||
'search'
|
||||
]
|
||||
},
|
||||
undefined
|
||||
)
|
||||
await expect(access.getChanges({})).resolves.toMatchObject({
|
||||
rootPath: binding.remoteRootPath,
|
||||
available: false,
|
||||
files: []
|
||||
})
|
||||
expect(getGitStatus).not.toHaveBeenCalled()
|
||||
expect(getGitDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resumes a previous handle and opens a replacement when resume fails', async () => {
|
||||
const lease = createLease({
|
||||
resumableHandle: handle,
|
||||
resumeWorkspace: vi.fn(async () => ({
|
||||
resumed: false,
|
||||
handle
|
||||
}))
|
||||
})
|
||||
const { access } = createAccess(lease)
|
||||
|
||||
await access.getIdentity()
|
||||
|
||||
expect(lease.resumeWorkspace).toHaveBeenCalledWith(
|
||||
{
|
||||
workspaceId: 'workspace-1',
|
||||
generation: 5,
|
||||
workspaceIdentity: 'identity-1'
|
||||
},
|
||||
undefined
|
||||
)
|
||||
expect(lease.openWorkspace).toHaveBeenCalledWith(
|
||||
{
|
||||
workspaceIdentity: 'identity-1',
|
||||
requestedAccess: 'read-only'
|
||||
},
|
||||
undefined
|
||||
)
|
||||
expect(lease.validateWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects immutable transport and workspace binding mismatches', async () => {
|
||||
for (const changedBinding of [
|
||||
{ hostKeyGeneration: 99 },
|
||||
{ agentBinaryDigest: `sha256:${'b'.repeat(64)}` },
|
||||
{ agentVersion: '0.11.1' },
|
||||
{ agentArchitecture: 'arm64' as const }
|
||||
]) {
|
||||
const release = vi.fn()
|
||||
const lease = createLease({
|
||||
binding: {
|
||||
...createLease().binding,
|
||||
...changedBinding
|
||||
},
|
||||
release
|
||||
})
|
||||
const { access } = createAccess(lease)
|
||||
|
||||
await expect(access.getIdentity()).rejects.toThrow(
|
||||
'绑定已失效'
|
||||
)
|
||||
expect(release).toHaveBeenCalledOnce()
|
||||
}
|
||||
|
||||
const identityMismatch = createLease({
|
||||
validateWorkspace: vi.fn(async () => ({
|
||||
handle: {
|
||||
...handle,
|
||||
workspaceIdentity: 'identity-2'
|
||||
},
|
||||
validatedAt: '2026-08-21T00:00:00.000Z'
|
||||
}))
|
||||
})
|
||||
await expect(
|
||||
createAccess(identityMismatch).access.getIdentity()
|
||||
).rejects.toThrow('身份绑定不匹配')
|
||||
})
|
||||
|
||||
it('fails when the lease capability generation becomes stale', async () => {
|
||||
const mutableBinding = {
|
||||
...createLease().binding
|
||||
}
|
||||
const lease = createLease({ binding: mutableBinding })
|
||||
const { access } = createAccess(lease)
|
||||
await access.getIdentity()
|
||||
mutableBinding.capabilityGeneration += 1
|
||||
|
||||
await expect(
|
||||
access.listDirectory({ path: '' })
|
||||
).rejects.toThrow('能力代际已失效')
|
||||
})
|
||||
|
||||
it('enforces protocol limits before dispatching requests', async () => {
|
||||
const lease = createLease()
|
||||
const { access, transport } = createAccess(lease)
|
||||
|
||||
await expect(
|
||||
access.listDirectory({ path: '', maximumEntries: 1001 })
|
||||
).rejects.toThrow('条目上限')
|
||||
await expect(
|
||||
access.readText({
|
||||
path: 'file.txt',
|
||||
maximumBytes: 4 * 1024 * 1024 + 1
|
||||
})
|
||||
).rejects.toThrow('读取上限')
|
||||
await expect(
|
||||
access.search({ query: 'x', maximumResults: 1001 })
|
||||
).rejects.toThrow('搜索结果上限')
|
||||
await expect(
|
||||
access.search({ query: 'x', maximumFileBytes: 1 })
|
||||
).rejects.toThrow('不支持本机扫描限额')
|
||||
await expect(
|
||||
access.writeTextAtomic({ path: 'x', content: 'x' })
|
||||
).rejects.toThrow('持久 operation transport')
|
||||
expect(transport.acquireLease).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries one inconsistent Git snapshot and maps status and diff', async () => {
|
||||
const getGitStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
repositoryIdentity: 'repository-1',
|
||||
branch: 'main',
|
||||
entries: [],
|
||||
truncated: false
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
repositoryIdentity: 'repository-2',
|
||||
branch: 'main',
|
||||
entries: [
|
||||
{
|
||||
relativePath: 'new.txt',
|
||||
index: 'untracked',
|
||||
worktree: 'untracked'
|
||||
}
|
||||
],
|
||||
truncated: false
|
||||
})
|
||||
const getGitDiff = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
repositoryIdentity: 'repository-other',
|
||||
patch: 'old',
|
||||
byteLength: 3,
|
||||
truncated: false
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
repositoryIdentity: 'repository-2',
|
||||
patch: '+new\n',
|
||||
byteLength: 5,
|
||||
truncated: false
|
||||
})
|
||||
const lease = createLease({ getGitStatus, getGitDiff })
|
||||
const { access } = createAccess(lease)
|
||||
|
||||
await expect(access.getChanges({})).resolves.toMatchObject({
|
||||
available: true,
|
||||
status: '?? new.txt',
|
||||
patch: '+new\n',
|
||||
files: [{ path: 'new.txt', status: '??' }]
|
||||
})
|
||||
expect(getGitStatus).toHaveBeenCalledTimes(2)
|
||||
expect(getGitDiff).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('fails closed after the bounded Git snapshot retry', async () => {
|
||||
const lease = createLease({
|
||||
getGitStatus: vi.fn(async () => ({
|
||||
repositoryIdentity: 'status-snapshot',
|
||||
branch: null,
|
||||
entries: [],
|
||||
truncated: false
|
||||
})),
|
||||
getGitDiff: vi.fn(async () => ({
|
||||
repositoryIdentity: 'diff-snapshot',
|
||||
patch: '',
|
||||
byteLength: 0,
|
||||
truncated: false
|
||||
}))
|
||||
})
|
||||
const { access } = createAccess(lease)
|
||||
|
||||
await expect(access.getChanges({})).resolves.toMatchObject({
|
||||
available: false,
|
||||
error: 'Git 快照在读取期间持续变化'
|
||||
})
|
||||
expect(lease.getGitStatus).toHaveBeenCalledTimes(2)
|
||||
expect(lease.getGitDiff).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('propagates cancellation to the transport', async () => {
|
||||
const controller = new AbortController()
|
||||
const listWorkspace = vi.fn(
|
||||
async (
|
||||
_request: RemoteWorkspaceListRequest,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(signal.reason),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
const lease = createLease({ listWorkspace })
|
||||
const { access } = createAccess(lease)
|
||||
const operation = access.listDirectory({
|
||||
path: '',
|
||||
signal: controller.signal
|
||||
})
|
||||
await vi.waitFor(() => expect(listWorkspace).toHaveBeenCalled())
|
||||
controller.abort(new Error('cancelled'))
|
||||
|
||||
await expect(operation).rejects.toThrow('cancelled')
|
||||
expect(listWorkspace.mock.calls[0]?.[1]).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('closes and releases exactly once even when close fails', async () => {
|
||||
const release = vi.fn()
|
||||
const closeWorkspace = vi.fn(async () => {
|
||||
throw new Error('close failed')
|
||||
})
|
||||
const lease = createLease({ release, closeWorkspace })
|
||||
const { access } = createAccess(lease)
|
||||
await access.getIdentity()
|
||||
|
||||
await expect(access.dispose()).rejects.toThrow('close failed')
|
||||
await expect(access.dispose()).rejects.toThrow('close failed')
|
||||
expect(closeWorkspace).toHaveBeenCalledOnce()
|
||||
expect(release).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,712 @@
|
||||
import {
|
||||
REMOTE_WORKSPACE_READ_CAPABILITIES,
|
||||
REMOTE_WORKSPACE_LIMITS,
|
||||
remoteGitDiffRequestSchema,
|
||||
remoteGitDiffResultSchema,
|
||||
remoteGitStatusRequestSchema,
|
||||
remoteGitStatusResultSchema,
|
||||
remoteWorkspaceCloseRequestSchema,
|
||||
remoteWorkspaceCloseResultSchema,
|
||||
remoteWorkspaceHandleSchema,
|
||||
remoteWorkspaceListRequestSchema,
|
||||
remoteWorkspaceListResultSchema,
|
||||
remoteWorkspaceOpenRequestSchema,
|
||||
remoteWorkspaceOpenResultSchema,
|
||||
remoteWorkspaceReadTextRequestSchema,
|
||||
remoteWorkspaceReadTextResultSchema,
|
||||
remoteWorkspaceResumeRequestSchema,
|
||||
remoteWorkspaceResumeResultSchema,
|
||||
remoteWorkspaceSearchRequestSchema,
|
||||
remoteWorkspaceSearchResultSchema,
|
||||
remoteWorkspaceStatRequestSchema,
|
||||
remoteWorkspaceStatResultSchema,
|
||||
remoteWorkspaceValidateRequestSchema,
|
||||
remoteWorkspaceValidateResultSchema,
|
||||
type RemoteGitDiffRequest,
|
||||
type RemoteGitDiffResult,
|
||||
type RemoteGitStatusRequest,
|
||||
type RemoteGitStatusResult,
|
||||
type RemoteWorkspaceCloseRequest,
|
||||
type RemoteWorkspaceCloseResult,
|
||||
type RemoteWorkspaceHandle,
|
||||
type RemoteWorkspaceListRequest,
|
||||
type RemoteWorkspaceListResult,
|
||||
type RemoteWorkspaceOpenRequest,
|
||||
type RemoteWorkspaceOpenResult,
|
||||
type RemoteWorkspaceReadTextRequest,
|
||||
type RemoteWorkspaceReadTextResult,
|
||||
type RemoteWorkspaceResumeRequest,
|
||||
type RemoteWorkspaceResumeResult,
|
||||
type RemoteWorkspaceSearchRequest,
|
||||
type RemoteWorkspaceSearchResult,
|
||||
type RemoteWorkspaceStatRequest,
|
||||
type RemoteWorkspaceStatResult,
|
||||
type RemoteWorkspaceValidateRequest,
|
||||
type RemoteWorkspaceValidateResult
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import type {
|
||||
DirectoryPage,
|
||||
FilePreview,
|
||||
SearchPage,
|
||||
WorkspaceAccess,
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceChanges,
|
||||
WorkspaceChangesInput,
|
||||
WorkspaceDirectoryInput,
|
||||
WorkspaceEntry,
|
||||
WorkspaceIdentity,
|
||||
WorkspacePathInput,
|
||||
WorkspaceReadInput,
|
||||
WorkspaceSearchInput,
|
||||
WorkspaceWriteInput,
|
||||
WriteResult
|
||||
} from './workspace-access'
|
||||
|
||||
const DEFAULT_DIRECTORY_ENTRIES = 500
|
||||
const DEFAULT_READ_BYTES = 256 * 1024
|
||||
const DEFAULT_SEARCH_RESULTS = 100
|
||||
export type RemoteWorkspaceProjectBinding = {
|
||||
hostId: string
|
||||
hostRevision: number
|
||||
hostKeyGeneration: number
|
||||
remoteUsername: string
|
||||
remoteRootPath: string
|
||||
workspaceIdentity: string
|
||||
agentInstallationId: string
|
||||
agentBinaryDigest: string
|
||||
agentVersion: string
|
||||
agentArchitecture: 'x64' | 'arm64'
|
||||
agentProtocolMajor: number
|
||||
}
|
||||
|
||||
export type RemoteWorkspaceTransportBinding = {
|
||||
hostId: string
|
||||
hostRevision: number
|
||||
hostKeyGeneration: number
|
||||
remoteUsername: string
|
||||
agentInstallationId: string
|
||||
agentBinaryDigest: string
|
||||
agentVersion: string
|
||||
agentArchitecture: 'x64' | 'arm64'
|
||||
agentProtocolMajor: number
|
||||
capabilityGeneration: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A process-neutral, already-authenticated Agent API lease. It deliberately
|
||||
* exposes neither an SSH connection nor a generic JSON-RPC method.
|
||||
*/
|
||||
export interface RemoteWorkspaceTransportLease {
|
||||
readonly binding: RemoteWorkspaceTransportBinding
|
||||
readonly resumableHandle?: RemoteWorkspaceHandle
|
||||
validateWorkspace(
|
||||
request: RemoteWorkspaceValidateRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceValidateResult>
|
||||
openWorkspace(
|
||||
request: RemoteWorkspaceOpenRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceOpenResult>
|
||||
resumeWorkspace(
|
||||
request: RemoteWorkspaceResumeRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceResumeResult>
|
||||
closeWorkspace(
|
||||
request: RemoteWorkspaceCloseRequest
|
||||
): Promise<RemoteWorkspaceCloseResult>
|
||||
listWorkspace(
|
||||
request: RemoteWorkspaceListRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceListResult>
|
||||
statWorkspace(
|
||||
request: RemoteWorkspaceStatRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceStatResult>
|
||||
readWorkspaceText(
|
||||
request: RemoteWorkspaceReadTextRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceReadTextResult>
|
||||
searchWorkspace(
|
||||
request: RemoteWorkspaceSearchRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceSearchResult>
|
||||
getGitStatus(
|
||||
request: RemoteGitStatusRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteGitStatusResult>
|
||||
getGitDiff(
|
||||
request: RemoteGitDiffRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteGitDiffResult>
|
||||
release(): void | Promise<void>
|
||||
}
|
||||
|
||||
export interface RemoteWorkspaceTransport {
|
||||
acquireLease(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceTransportLease>
|
||||
}
|
||||
|
||||
export interface RemoteWorkspaceAccessFactory {
|
||||
create(binding: RemoteWorkspaceProjectBinding): WorkspaceAccess
|
||||
}
|
||||
|
||||
type OpenRemoteWorkspace = {
|
||||
lease: RemoteWorkspaceTransportLease
|
||||
handle: RemoteWorkspaceHandle
|
||||
capabilityGeneration: number
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: number | undefined,
|
||||
defaultValue: number,
|
||||
maximum: number,
|
||||
message: string
|
||||
): number {
|
||||
const resolved = value ?? defaultValue
|
||||
if (
|
||||
!Number.isSafeInteger(resolved) ||
|
||||
resolved < 1 ||
|
||||
resolved > maximum
|
||||
) {
|
||||
throw new Error(message)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function assertTransportBinding(
|
||||
expected: RemoteWorkspaceProjectBinding,
|
||||
actual: RemoteWorkspaceTransportBinding
|
||||
): void {
|
||||
if (
|
||||
!Number.isSafeInteger(actual.capabilityGeneration) ||
|
||||
actual.capabilityGeneration < 1 ||
|
||||
actual.hostId !== expected.hostId ||
|
||||
actual.hostRevision !== expected.hostRevision ||
|
||||
actual.hostKeyGeneration !== expected.hostKeyGeneration ||
|
||||
actual.remoteUsername !== expected.remoteUsername ||
|
||||
actual.agentInstallationId !== expected.agentInstallationId ||
|
||||
actual.agentBinaryDigest !== expected.agentBinaryDigest ||
|
||||
actual.agentVersion !== expected.agentVersion ||
|
||||
actual.agentArchitecture !== expected.agentArchitecture ||
|
||||
actual.agentProtocolMajor !== expected.agentProtocolMajor
|
||||
) {
|
||||
throw new Error('远程工作区连接绑定已失效')
|
||||
}
|
||||
}
|
||||
|
||||
function assertHandleBinding(
|
||||
expected: RemoteWorkspaceProjectBinding,
|
||||
handleInput: unknown
|
||||
): RemoteWorkspaceHandle {
|
||||
const handle = remoteWorkspaceHandleSchema.parse(handleInput)
|
||||
if (
|
||||
handle.workspaceIdentity !== expected.workspaceIdentity ||
|
||||
handle.access !== 'read-only'
|
||||
) {
|
||||
throw new Error('远程工作区身份绑定不匹配')
|
||||
}
|
||||
for (const capability of REMOTE_WORKSPACE_READ_CAPABILITIES) {
|
||||
if (!handle.capabilities.includes(capability)) {
|
||||
throw new Error(`远程工作区缺少必要能力:${capability}`)
|
||||
}
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
function entryType(
|
||||
kind: 'file' | 'directory' | 'symlink' | 'other'
|
||||
): 'file' | 'directory' | 'other' {
|
||||
return kind === 'file' || kind === 'directory' ? kind : 'other'
|
||||
}
|
||||
|
||||
function gitStatusCharacter(
|
||||
status:
|
||||
| 'unmodified'
|
||||
| 'added'
|
||||
| 'modified'
|
||||
| 'deleted'
|
||||
| 'renamed'
|
||||
| 'copied'
|
||||
| 'unmerged'
|
||||
| 'untracked'
|
||||
| 'ignored'
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'unmodified':
|
||||
return ' '
|
||||
case 'added':
|
||||
return 'A'
|
||||
case 'modified':
|
||||
return 'M'
|
||||
case 'deleted':
|
||||
return 'D'
|
||||
case 'renamed':
|
||||
return 'R'
|
||||
case 'copied':
|
||||
return 'C'
|
||||
case 'unmerged':
|
||||
return 'U'
|
||||
case 'untracked':
|
||||
return '?'
|
||||
case 'ignored':
|
||||
return '!'
|
||||
}
|
||||
}
|
||||
|
||||
function mapChangedFiles(
|
||||
result: RemoteGitStatusResult
|
||||
): WorkspaceChangedFile[] {
|
||||
return result.entries.map((entry) => {
|
||||
const status =
|
||||
entry.index === 'untracked' || entry.worktree === 'untracked'
|
||||
? '??'
|
||||
: entry.index === 'ignored' || entry.worktree === 'ignored'
|
||||
? '!!'
|
||||
: `${gitStatusCharacter(entry.index)}${gitStatusCharacter(entry.worktree)}`
|
||||
return {
|
||||
path: entry.relativePath,
|
||||
status,
|
||||
...(entry.originalRelativePath
|
||||
? { previousPath: entry.originalRelativePath }
|
||||
: {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatChangedFiles(files: WorkspaceChangedFile[]): string {
|
||||
return files
|
||||
.map((file) =>
|
||||
file.previousPath
|
||||
? `${file.status} ${file.previousPath} -> ${file.path}`
|
||||
: `${file.status} ${file.path}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
private opening?: Promise<OpenRemoteWorkspace>
|
||||
private opened?: OpenRemoteWorkspace
|
||||
private disposed = false
|
||||
private disposal?: Promise<void>
|
||||
|
||||
constructor(
|
||||
private readonly binding: RemoteWorkspaceProjectBinding,
|
||||
private readonly transport: RemoteWorkspaceTransport
|
||||
) {}
|
||||
|
||||
private async open(signal?: AbortSignal): Promise<OpenRemoteWorkspace> {
|
||||
signal?.throwIfAborted()
|
||||
if (this.disposed) {
|
||||
throw new Error('远程工作区访问已关闭')
|
||||
}
|
||||
if (this.opened) {
|
||||
assertTransportBinding(this.binding, this.opened.lease.binding)
|
||||
return this.opened
|
||||
}
|
||||
this.opening ??= this.openNew(signal)
|
||||
try {
|
||||
const opened = await this.opening
|
||||
if (this.disposed) {
|
||||
throw new Error('远程工作区访问已关闭')
|
||||
}
|
||||
this.opened = opened
|
||||
signal?.throwIfAborted()
|
||||
return opened
|
||||
} catch (error) {
|
||||
this.opening = undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async openNew(
|
||||
signal?: AbortSignal
|
||||
): Promise<OpenRemoteWorkspace> {
|
||||
const lease = await this.transport.acquireLease(this.binding, signal)
|
||||
let retained = false
|
||||
try {
|
||||
assertTransportBinding(this.binding, lease.binding)
|
||||
signal?.throwIfAborted()
|
||||
let handle: RemoteWorkspaceHandle
|
||||
if (lease.resumableHandle) {
|
||||
const previous = assertHandleBinding(
|
||||
this.binding,
|
||||
lease.resumableHandle
|
||||
)
|
||||
const resumed = remoteWorkspaceResumeResultSchema.parse(
|
||||
await lease.resumeWorkspace(
|
||||
remoteWorkspaceResumeRequestSchema.parse({
|
||||
workspaceId: previous.workspaceId,
|
||||
generation: previous.generation,
|
||||
workspaceIdentity: this.binding.workspaceIdentity
|
||||
}),
|
||||
signal
|
||||
)
|
||||
)
|
||||
handle = resumed.resumed
|
||||
? assertHandleBinding(this.binding, resumed.handle)
|
||||
: assertHandleBinding(
|
||||
this.binding,
|
||||
remoteWorkspaceOpenResultSchema.parse(
|
||||
await lease.openWorkspace(
|
||||
remoteWorkspaceOpenRequestSchema.parse({
|
||||
workspaceIdentity: this.binding.workspaceIdentity,
|
||||
requestedAccess: 'read-only'
|
||||
}),
|
||||
signal
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
const validation = remoteWorkspaceValidateResultSchema.parse(
|
||||
await lease.validateWorkspace(
|
||||
remoteWorkspaceValidateRequestSchema.parse({
|
||||
remoteRootPath: this.binding.remoteRootPath,
|
||||
requestedAccess: 'read-only',
|
||||
requiredCapabilities: [
|
||||
...REMOTE_WORKSPACE_READ_CAPABILITIES
|
||||
]
|
||||
}),
|
||||
signal
|
||||
)
|
||||
)
|
||||
handle = assertHandleBinding(this.binding, validation.handle)
|
||||
}
|
||||
assertTransportBinding(this.binding, lease.binding)
|
||||
retained = true
|
||||
return {
|
||||
lease,
|
||||
handle,
|
||||
capabilityGeneration: lease.binding.capabilityGeneration
|
||||
}
|
||||
} finally {
|
||||
if (!retained) {
|
||||
await lease.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private reference(opened: OpenRemoteWorkspace): {
|
||||
workspaceId: string
|
||||
generation: number
|
||||
} {
|
||||
assertTransportBinding(this.binding, opened.lease.binding)
|
||||
if (
|
||||
opened.lease.binding.capabilityGeneration !==
|
||||
opened.capabilityGeneration
|
||||
) {
|
||||
throw new Error('远程工作区能力代际已失效')
|
||||
}
|
||||
return {
|
||||
workspaceId: opened.handle.workspaceId,
|
||||
generation: opened.handle.generation
|
||||
}
|
||||
}
|
||||
|
||||
async getIdentity(): Promise<WorkspaceIdentity> {
|
||||
const { handle } = await this.open()
|
||||
return {
|
||||
kind: 'remote',
|
||||
id: `${this.binding.hostId}:${handle.workspaceIdentity}`,
|
||||
canonicalDisplayPath: handle.canonicalDisplayPath,
|
||||
access: 'read-only'
|
||||
}
|
||||
}
|
||||
|
||||
async listDirectory(
|
||||
input: WorkspaceDirectoryInput
|
||||
): Promise<DirectoryPage> {
|
||||
const maximumEntries = boundedInteger(
|
||||
input.maximumEntries,
|
||||
DEFAULT_DIRECTORY_ENTRIES,
|
||||
REMOTE_WORKSPACE_LIMITS.maximumDirectoryEntriesPerPage,
|
||||
'工作区目录条目上限无效'
|
||||
)
|
||||
const opened = await this.open(input.signal)
|
||||
const request = remoteWorkspaceListRequestSchema.parse({
|
||||
...this.reference(opened),
|
||||
relativePath: input.path,
|
||||
limit: maximumEntries
|
||||
})
|
||||
const result = remoteWorkspaceListResultSchema.parse(
|
||||
await opened.lease.listWorkspace(request, input.signal)
|
||||
)
|
||||
const entries = result.entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
(input.includeGit === true || entry.name !== '.git') &&
|
||||
(input.includeOther === true ||
|
||||
entryType(entry.kind) !== 'other')
|
||||
)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.relativePath,
|
||||
type: entryType(entry.kind)
|
||||
}))
|
||||
return {
|
||||
path: input.path,
|
||||
entries,
|
||||
truncated: result.nextCursor !== undefined
|
||||
}
|
||||
}
|
||||
|
||||
async stat(input: WorkspacePathInput): Promise<WorkspaceEntry> {
|
||||
const opened = await this.open(input.signal)
|
||||
const request = remoteWorkspaceStatRequestSchema.parse({
|
||||
...this.reference(opened),
|
||||
relativePath: input.path
|
||||
})
|
||||
const result = remoteWorkspaceStatResultSchema.parse(
|
||||
await opened.lease.statWorkspace(request, input.signal)
|
||||
)
|
||||
if (
|
||||
result.relativePath !== input.path
|
||||
) {
|
||||
throw new Error('远程工作区返回了不匹配的文件元数据')
|
||||
}
|
||||
return {
|
||||
name: result.name,
|
||||
path: result.relativePath,
|
||||
type: entryType(result.kind),
|
||||
size: result.byteLength ?? 0,
|
||||
modifiedAt: result.modifiedAt ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
async readText(input: WorkspaceReadInput): Promise<FilePreview> {
|
||||
const maximumBytes = boundedInteger(
|
||||
input.maximumBytes,
|
||||
DEFAULT_READ_BYTES,
|
||||
REMOTE_WORKSPACE_LIMITS.maximumReadBytes,
|
||||
'工作区读取上限无效'
|
||||
)
|
||||
const opened = await this.open(input.signal)
|
||||
const request = remoteWorkspaceReadTextRequestSchema.parse({
|
||||
...this.reference(opened),
|
||||
relativePath: input.path,
|
||||
offsetBytes: 0,
|
||||
maximumBytes
|
||||
})
|
||||
const rawResult = await opened.lease.readWorkspaceText(
|
||||
request,
|
||||
input.signal
|
||||
)
|
||||
let result: RemoteWorkspaceReadTextResult
|
||||
try {
|
||||
result = remoteWorkspaceReadTextResultSchema.parse(rawResult)
|
||||
} catch (error) {
|
||||
if (input.signal?.aborted) {
|
||||
throw error
|
||||
}
|
||||
throw new Error(
|
||||
input.invalidUtf8Message ??
|
||||
'工作区读取目标不是有效 UTF-8 文本',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
if (
|
||||
result.relativePath !== input.path ||
|
||||
result.offsetBytes !== 0
|
||||
) {
|
||||
throw new Error('远程工作区返回了不匹配的文件')
|
||||
}
|
||||
if (result.truncated || result.bytesRead !== result.totalBytes) {
|
||||
throw new Error(
|
||||
input.tooLargeMessage ?? '工作区文本文件超过安全限制'
|
||||
)
|
||||
}
|
||||
const name = input.path.split('/').at(-1) ?? input.path
|
||||
return {
|
||||
path: input.path,
|
||||
name,
|
||||
content: result.content,
|
||||
size: result.totalBytes
|
||||
}
|
||||
}
|
||||
|
||||
async writeTextAtomic(
|
||||
input: WorkspaceWriteInput
|
||||
): Promise<WriteResult> {
|
||||
input.signal?.throwIfAborted()
|
||||
throw new Error(
|
||||
'远程工作区写入需要可恢复的持久 operation transport'
|
||||
)
|
||||
}
|
||||
|
||||
async search(input: WorkspaceSearchInput): Promise<SearchPage> {
|
||||
if (
|
||||
input.maximumFileBytes !== undefined ||
|
||||
input.maximumScannedBytes !== undefined
|
||||
) {
|
||||
throw new Error('远程工作区搜索不支持本机扫描限额')
|
||||
}
|
||||
const maximumResults = boundedInteger(
|
||||
input.maximumResults,
|
||||
DEFAULT_SEARCH_RESULTS,
|
||||
REMOTE_WORKSPACE_LIMITS.maximumSearchMatchesPerPage,
|
||||
'工作区搜索结果上限无效'
|
||||
)
|
||||
const opened = await this.open(input.signal)
|
||||
const request = remoteWorkspaceSearchRequestSchema.parse({
|
||||
...this.reference(opened),
|
||||
query: input.query,
|
||||
...(input.path === undefined ? {} : { pathPrefix: input.path }),
|
||||
caseSensitive: true,
|
||||
limit: maximumResults
|
||||
})
|
||||
const result = remoteWorkspaceSearchResultSchema.parse(
|
||||
await opened.lease.searchWorkspace(request, input.signal)
|
||||
)
|
||||
return {
|
||||
matches: result.matches.map((match) => ({
|
||||
path: match.relativePath,
|
||||
line: match.line,
|
||||
column: match.column,
|
||||
preview: match.snippet
|
||||
})),
|
||||
truncated:
|
||||
result.truncated || result.nextCursor !== undefined
|
||||
}
|
||||
}
|
||||
|
||||
async getChanges(
|
||||
input: WorkspaceChangesInput
|
||||
): Promise<WorkspaceChanges> {
|
||||
try {
|
||||
const opened = await this.open(input.signal)
|
||||
if (opened.handle.git !== 'available') {
|
||||
return {
|
||||
rootPath: opened.handle.canonicalDisplayPath,
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
input.signal?.throwIfAborted()
|
||||
const reference = this.reference(opened)
|
||||
const [status, diff] = await Promise.all([
|
||||
opened.lease
|
||||
.getGitStatus(
|
||||
remoteGitStatusRequestSchema.parse({
|
||||
...reference,
|
||||
includeIgnored: false,
|
||||
maximumEntries:
|
||||
REMOTE_WORKSPACE_LIMITS.maximumGitStatusEntries
|
||||
}),
|
||||
input.signal
|
||||
)
|
||||
.then((result) =>
|
||||
remoteGitStatusResultSchema.parse(result)
|
||||
),
|
||||
opened.lease
|
||||
.getGitDiff(
|
||||
remoteGitDiffRequestSchema.parse({
|
||||
...reference,
|
||||
staged: false,
|
||||
maximumBytes:
|
||||
REMOTE_WORKSPACE_LIMITS.maximumGitDiffBytes
|
||||
}),
|
||||
input.signal
|
||||
)
|
||||
.then((result) =>
|
||||
remoteGitDiffResultSchema.parse(result)
|
||||
)
|
||||
])
|
||||
if (status.repositoryIdentity !== diff.repositoryIdentity) {
|
||||
if (attempt === 0) {
|
||||
continue
|
||||
}
|
||||
throw new Error('Git 快照在读取期间持续变化')
|
||||
}
|
||||
const files = mapChangedFiles(status)
|
||||
return {
|
||||
rootPath: opened.handle.canonicalDisplayPath,
|
||||
available: true,
|
||||
status: formatChangedFiles(files),
|
||||
patch: diff.patch,
|
||||
files,
|
||||
truncated:
|
||||
status.truncated ||
|
||||
diff.truncated ||
|
||||
diff.nextCursor !== undefined
|
||||
}
|
||||
}
|
||||
throw new Error('Git 快照读取失败')
|
||||
} catch (error) {
|
||||
if (input.signal?.aborted) {
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
rootPath: this.binding.remoteRootPath,
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '无法读取远程 Git 工作区'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposal ??= this.disposeOnce()
|
||||
return await this.disposal
|
||||
}
|
||||
|
||||
private async disposeOnce(): Promise<void> {
|
||||
this.disposed = true
|
||||
let opened = this.opened
|
||||
if (!opened && this.opening) {
|
||||
try {
|
||||
opened = await this.opening
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!opened) {
|
||||
return
|
||||
}
|
||||
let closeError: unknown
|
||||
try {
|
||||
const request = remoteWorkspaceCloseRequestSchema.parse(
|
||||
{
|
||||
workspaceId: opened.handle.workspaceId,
|
||||
generation: opened.handle.generation
|
||||
}
|
||||
)
|
||||
const result = remoteWorkspaceCloseResultSchema.parse(
|
||||
await opened.lease.closeWorkspace(request)
|
||||
)
|
||||
if (
|
||||
result.workspaceId !== request.workspaceId ||
|
||||
result.generation !== request.generation
|
||||
) {
|
||||
throw new Error('远程工作区关闭确认不匹配')
|
||||
}
|
||||
} catch (error) {
|
||||
closeError = error
|
||||
}
|
||||
try {
|
||||
await opened.lease.release()
|
||||
} catch (releaseError) {
|
||||
if (closeError === undefined) {
|
||||
throw releaseError
|
||||
}
|
||||
throw new AggregateError(
|
||||
[closeError, releaseError],
|
||||
'关闭远程工作区失败',
|
||||
{ cause: releaseError }
|
||||
)
|
||||
}
|
||||
if (closeError !== undefined) {
|
||||
throw closeError
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
DirectoryPage,
|
||||
FilePreview,
|
||||
SearchPage,
|
||||
WorkspaceAccess,
|
||||
WorkspaceChanges,
|
||||
WorkspaceChangesInput,
|
||||
WorkspaceDirectoryInput,
|
||||
WorkspaceEntry,
|
||||
WorkspaceIdentity,
|
||||
WorkspacePathInput,
|
||||
WorkspaceReadInput,
|
||||
WorkspaceSearchInput,
|
||||
WorkspaceWriteInput,
|
||||
WriteResult
|
||||
} from './workspace-access'
|
||||
|
||||
const unsupportedRemoteMessage = '远程工作区访问尚不可用'
|
||||
|
||||
export class UnsupportedRemoteWorkspaceAccess
|
||||
implements WorkspaceAccess {
|
||||
constructor(
|
||||
private readonly message: string = unsupportedRemoteMessage
|
||||
) {}
|
||||
|
||||
private unsupported(): never {
|
||||
throw new Error(this.message)
|
||||
}
|
||||
|
||||
async getIdentity(): Promise<WorkspaceIdentity> {
|
||||
return this.unsupported()
|
||||
}
|
||||
|
||||
async listDirectory(
|
||||
input: WorkspaceDirectoryInput
|
||||
): Promise<DirectoryPage> {
|
||||
void input
|
||||
return this.unsupported()
|
||||
}
|
||||
|
||||
async stat(input: WorkspacePathInput): Promise<WorkspaceEntry> {
|
||||
void input
|
||||
return this.unsupported()
|
||||
}
|
||||
|
||||
async readText(input: WorkspaceReadInput): Promise<FilePreview> {
|
||||
void input
|
||||
return this.unsupported()
|
||||
}
|
||||
|
||||
async writeTextAtomic(
|
||||
input: WorkspaceWriteInput
|
||||
): Promise<WriteResult> {
|
||||
void input
|
||||
return this.unsupported()
|
||||
}
|
||||
|
||||
async search(input: WorkspaceSearchInput): Promise<SearchPage> {
|
||||
void input
|
||||
return this.unsupported()
|
||||
}
|
||||
|
||||
async getChanges(
|
||||
input: WorkspaceChangesInput
|
||||
): Promise<WorkspaceChanges> {
|
||||
void input
|
||||
return this.unsupported()
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
// Cleanup is always safe and idempotent, even when access is unsupported.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
LocalWorkspaceAccess,
|
||||
UnsupportedRemoteWorkspaceAccess
|
||||
} from './index'
|
||||
import { buildWorkspaceGitEnvironment } from './local-workspace-access'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
async function createWorkspace(): Promise<string> {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-workspace-access-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function runGit(
|
||||
directory: string,
|
||||
...args: string[]
|
||||
): Promise<void> {
|
||||
await execFileAsync('git', args, {
|
||||
cwd: directory,
|
||||
windowsHide: true
|
||||
})
|
||||
}
|
||||
|
||||
async function createGitWorkspace(): Promise<string> {
|
||||
const directory = await createWorkspace()
|
||||
await runGit(directory, 'init')
|
||||
await runGit(directory, 'config', 'user.name', 'GoodBuddy Test')
|
||||
await runGit(
|
||||
directory,
|
||||
'config',
|
||||
'user.email',
|
||||
'goodbuddy@example.invalid'
|
||||
)
|
||||
await writeFile(join(directory, 'tracked.txt'), 'original\n', 'utf8')
|
||||
await runGit(directory, 'add', 'tracked.txt')
|
||||
await runGit(directory, 'commit', '-m', 'initial')
|
||||
return directory
|
||||
}
|
||||
|
||||
async function createMarkerCommand(
|
||||
directory: string,
|
||||
name: string,
|
||||
markerPath: string,
|
||||
passthrough: boolean
|
||||
): Promise<string> {
|
||||
if (process.platform === 'win32') {
|
||||
const commandPath = join(directory, `${name}.cmd`)
|
||||
await writeFile(
|
||||
commandPath,
|
||||
[
|
||||
'@echo off',
|
||||
`> "${markerPath}" echo invoked`,
|
||||
...(passthrough ? ['type "%~1"'] : []),
|
||||
'exit /b 0',
|
||||
''
|
||||
].join('\r\n'),
|
||||
'utf8'
|
||||
)
|
||||
return commandPath
|
||||
}
|
||||
|
||||
const commandPath = join(directory, `${name}.sh`)
|
||||
await writeFile(
|
||||
commandPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`printf invoked > '${markerPath.replaceAll("'", "'\\''")}'`,
|
||||
...(passthrough ? ['cat "$1"'] : []),
|
||||
''
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
await chmod(commandPath, 0o700)
|
||||
return commandPath
|
||||
}
|
||||
|
||||
function gitCommandValue(commandPath: string): string {
|
||||
return `"${commandPath.replaceAll('\\', '/').replaceAll('"', '\\"')}"`
|
||||
}
|
||||
|
||||
describe('LocalWorkspaceAccess', () => {
|
||||
it('provides bounded local identity, file, search, and change operations', async () => {
|
||||
const directory = await createWorkspace()
|
||||
await mkdir(join(directory, 'docs'))
|
||||
await writeFile(
|
||||
join(directory, 'docs', 'guide.txt'),
|
||||
'first\nsearch target\n',
|
||||
'utf8'
|
||||
)
|
||||
const access = new LocalWorkspaceAccess(directory)
|
||||
|
||||
await expect(access.getIdentity()).resolves.toMatchObject({
|
||||
kind: 'local',
|
||||
canonicalDisplayPath: directory,
|
||||
access: 'read-write'
|
||||
})
|
||||
await expect(
|
||||
access.listDirectory({ path: '', maximumEntries: 10 })
|
||||
).resolves.toMatchObject({
|
||||
entries: [
|
||||
{ name: 'docs', path: 'docs', type: 'directory' }
|
||||
],
|
||||
truncated: false
|
||||
})
|
||||
await expect(
|
||||
access.stat({ path: 'docs/guide.txt' })
|
||||
).resolves.toMatchObject({
|
||||
name: 'guide.txt',
|
||||
path: 'docs/guide.txt',
|
||||
type: 'file'
|
||||
})
|
||||
await expect(
|
||||
access.readText({
|
||||
path: 'docs/guide.txt',
|
||||
maximumBytes: 256
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
content: 'first\nsearch target\n',
|
||||
size: 20
|
||||
})
|
||||
await expect(
|
||||
access.search({
|
||||
query: 'target',
|
||||
maximumResults: 10
|
||||
})
|
||||
).resolves.toEqual({
|
||||
matches: [
|
||||
{
|
||||
path: 'docs/guide.txt',
|
||||
line: 2,
|
||||
column: 8,
|
||||
preview: 'search target'
|
||||
}
|
||||
],
|
||||
truncated: false
|
||||
})
|
||||
await expect(
|
||||
access.writeTextAtomic({
|
||||
path: 'docs/output.txt',
|
||||
content: 'saved',
|
||||
maximumBytes: 32
|
||||
})
|
||||
).resolves.toEqual({
|
||||
path: 'docs/output.txt',
|
||||
bytesWritten: 5
|
||||
})
|
||||
await expect(
|
||||
readFile(join(directory, 'docs', 'output.txt'), 'utf8')
|
||||
).resolves.toBe('saved')
|
||||
await expect(access.getChanges({})).resolves.toMatchObject({
|
||||
rootPath: directory,
|
||||
available: false,
|
||||
files: []
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects traversal and bounded read and write overflows', async () => {
|
||||
const directory = await createWorkspace()
|
||||
await writeFile(join(directory, 'file.txt'), '12345', 'utf8')
|
||||
const access = new LocalWorkspaceAccess(directory)
|
||||
|
||||
await expect(
|
||||
access.readText({ path: '../file.txt' })
|
||||
).rejects.toThrow('不能超出工作区')
|
||||
await expect(
|
||||
access.readText({ path: 'file.txt', maximumBytes: 4 })
|
||||
).rejects.toThrow('安全限制')
|
||||
await expect(
|
||||
access.writeTextAtomic({
|
||||
path: 'output.txt',
|
||||
content: '12345',
|
||||
maximumBytes: 4
|
||||
})
|
||||
).rejects.toThrow('512KB')
|
||||
})
|
||||
|
||||
it('reports ordinary tracked and untracked Git changes', async () => {
|
||||
const directory = await createGitWorkspace()
|
||||
await writeFile(join(directory, 'tracked.txt'), 'changed\n', 'utf8')
|
||||
await writeFile(join(directory, 'untracked.txt'), 'new\n', 'utf8')
|
||||
|
||||
await expect(
|
||||
new LocalWorkspaceAccess(directory).getChanges({})
|
||||
).resolves.toMatchObject({
|
||||
rootPath: directory,
|
||||
available: true,
|
||||
files: [
|
||||
{ path: 'tracked.txt', status: ' M' },
|
||||
{ path: 'untracked.txt', status: '??' }
|
||||
],
|
||||
truncated: false
|
||||
})
|
||||
const changes = await new LocalWorkspaceAccess(
|
||||
directory
|
||||
).getChanges({})
|
||||
expect(changes.patch).toContain('-original')
|
||||
expect(changes.patch).toContain('+changed')
|
||||
})
|
||||
|
||||
it('does not run a repository-configured fsmonitor command', async () => {
|
||||
const directory = await createGitWorkspace()
|
||||
const markerPath = join(directory, 'fsmonitor-invoked')
|
||||
const commandPath = await createMarkerCommand(
|
||||
directory,
|
||||
'fsmonitor',
|
||||
markerPath,
|
||||
false
|
||||
)
|
||||
await runGit(
|
||||
directory,
|
||||
'config',
|
||||
'core.fsmonitor',
|
||||
gitCommandValue(commandPath)
|
||||
)
|
||||
await writeFile(join(directory, 'tracked.txt'), 'changed\n', 'utf8')
|
||||
await runGit(directory, 'status', '--porcelain')
|
||||
await expect(readFile(markerPath, 'utf8')).resolves.toContain(
|
||||
'invoked'
|
||||
)
|
||||
await rm(markerPath)
|
||||
|
||||
const changes = await new LocalWorkspaceAccess(
|
||||
directory
|
||||
).getChanges({})
|
||||
|
||||
expect(changes.available).toBe(true)
|
||||
await expect(readFile(markerPath, 'utf8')).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not run a repository-configured textconv command', async () => {
|
||||
const directory = await createGitWorkspace()
|
||||
const markerPath = join(directory, 'textconv-invoked')
|
||||
const commandPath = await createMarkerCommand(
|
||||
directory,
|
||||
'textconv',
|
||||
markerPath,
|
||||
true
|
||||
)
|
||||
await writeFile(
|
||||
join(directory, '.gitattributes'),
|
||||
'tracked.txt diff=marker\n',
|
||||
'utf8'
|
||||
)
|
||||
await runGit(
|
||||
directory,
|
||||
'config',
|
||||
'diff.marker.textconv',
|
||||
gitCommandValue(commandPath)
|
||||
)
|
||||
await runGit(directory, 'add', '.gitattributes')
|
||||
await runGit(directory, 'commit', '-m', 'configure attributes')
|
||||
await writeFile(join(directory, 'tracked.txt'), 'changed\n', 'utf8')
|
||||
await runGit(directory, 'diff', '--textconv', 'HEAD')
|
||||
await expect(readFile(markerPath, 'utf8')).resolves.toContain(
|
||||
'invoked'
|
||||
)
|
||||
await rm(markerPath)
|
||||
|
||||
const changes = await new LocalWorkspaceAccess(
|
||||
directory
|
||||
).getChanges({})
|
||||
|
||||
expect(changes.available).toBe(true)
|
||||
expect(changes.patch).toContain('+changed')
|
||||
await expect(readFile(markerPath, 'utf8')).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
})
|
||||
|
||||
it('neutralizes filter names containing an equals sign', async () => {
|
||||
const directory = await createGitWorkspace()
|
||||
const markerPath = join(directory, 'filter-invoked')
|
||||
const commandPath = await createMarkerCommand(
|
||||
directory,
|
||||
'filter',
|
||||
markerPath,
|
||||
true
|
||||
)
|
||||
await writeFile(
|
||||
join(directory, '.gitattributes'),
|
||||
'tracked.txt filter=foo=bar\n',
|
||||
'utf8'
|
||||
)
|
||||
await runGit(
|
||||
directory,
|
||||
'config',
|
||||
'filter.foo=bar.clean',
|
||||
gitCommandValue(commandPath)
|
||||
)
|
||||
await runGit(directory, 'add', '.gitattributes')
|
||||
await runGit(directory, 'commit', '-m', 'configure filter')
|
||||
await rm(markerPath, { force: true })
|
||||
await writeFile(join(directory, 'tracked.txt'), 'changed\n', 'utf8')
|
||||
|
||||
const changes = await new LocalWorkspaceAccess(
|
||||
directory
|
||||
).getChanges({})
|
||||
|
||||
expect(changes.available).toBe(true)
|
||||
expect(changes.patch).toContain('+changed')
|
||||
await expect(readFile(markerPath, 'utf8')).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a fresh Git environment without Main-process secrets', () => {
|
||||
const environment = buildWorkspaceGitEnvironment({
|
||||
PATH: '/safe/bin',
|
||||
PATHEXT: '.EXE',
|
||||
SystemRoot: 'C:\\Windows',
|
||||
LANG: 'en_US.UTF-8',
|
||||
ANTHROPIC_API_KEY: 'provider-secret',
|
||||
OPENAI_API_KEY: 'provider-secret',
|
||||
GIT_TRACE: '1',
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'core.fsmonitor',
|
||||
GIT_CONFIG_VALUE_0: 'attacker-command'
|
||||
})
|
||||
|
||||
expect(environment).toMatchObject({
|
||||
PATH: '/safe/bin',
|
||||
PATHEXT: '.EXE',
|
||||
SystemRoot: 'C:\\Windows',
|
||||
LANG: 'en_US.UTF-8',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_OPTIONAL_LOCKS: '0',
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GCM_INTERACTIVE: 'Never'
|
||||
})
|
||||
expect(environment).not.toHaveProperty('ANTHROPIC_API_KEY')
|
||||
expect(environment).not.toHaveProperty('OPENAI_API_KEY')
|
||||
expect(environment).not.toHaveProperty('GIT_TRACE')
|
||||
expect(environment).not.toHaveProperty('GIT_CONFIG_COUNT')
|
||||
expect(environment).not.toHaveProperty('GIT_CONFIG_KEY_0')
|
||||
expect(environment).not.toHaveProperty('GIT_CONFIG_VALUE_0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UnsupportedRemoteWorkspaceAccess', () => {
|
||||
it('fails closed for every operation', async () => {
|
||||
const access = new UnsupportedRemoteWorkspaceAccess()
|
||||
const operations = [
|
||||
access.getIdentity(),
|
||||
access.listDirectory({ path: '' }),
|
||||
access.stat({ path: 'file.txt' }),
|
||||
access.readText({ path: 'file.txt' }),
|
||||
access.writeTextAtomic({ path: 'file.txt', content: 'x' }),
|
||||
access.search({ query: 'x' }),
|
||||
access.getChanges({})
|
||||
]
|
||||
|
||||
for (const operation of operations) {
|
||||
await expect(operation).rejects.toThrow('远程工作区访问尚不可用')
|
||||
}
|
||||
await expect(access.dispose()).resolves.toBeUndefined()
|
||||
await expect(access.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
WorkspaceChanges,
|
||||
WorkspaceChangedFile
|
||||
} from '../../shared/assistant-contracts'
|
||||
|
||||
export type WorkspaceIdentity = {
|
||||
kind: 'local' | 'remote'
|
||||
id: string
|
||||
canonicalDisplayPath: string
|
||||
access: 'read-only' | 'read-write'
|
||||
}
|
||||
|
||||
export type WorkspacePathInput = {
|
||||
path: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type WorkspaceDirectoryInput = WorkspacePathInput & {
|
||||
maximumEntries?: number
|
||||
includeGit?: boolean
|
||||
includeOther?: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceDirectoryEntry = {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'directory' | 'other'
|
||||
}
|
||||
|
||||
export type DirectoryPage = {
|
||||
path: string
|
||||
entries: WorkspaceDirectoryEntry[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceEntry = WorkspaceDirectoryEntry & {
|
||||
size: number
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
export type WorkspaceReadInput = WorkspacePathInput & {
|
||||
maximumBytes?: number
|
||||
tooLargeMessage?: string
|
||||
invalidUtf8Message?: string
|
||||
}
|
||||
|
||||
export type FilePreview = {
|
||||
path: string
|
||||
name: string
|
||||
content: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export type WorkspaceWriteInput = WorkspacePathInput & {
|
||||
content: string
|
||||
maximumBytes?: number
|
||||
}
|
||||
|
||||
export type WriteResult = {
|
||||
path: string
|
||||
bytesWritten: number
|
||||
}
|
||||
|
||||
export type WorkspaceSearchInput = {
|
||||
query: string
|
||||
path?: string
|
||||
maximumResults?: number
|
||||
maximumFileBytes?: number
|
||||
maximumScannedBytes?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type WorkspaceSearchMatch = {
|
||||
path: string
|
||||
line: number
|
||||
column: number
|
||||
preview: string
|
||||
}
|
||||
|
||||
export type SearchPage = {
|
||||
matches: WorkspaceSearchMatch[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceChangesInput = {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type { WorkspaceChanges, WorkspaceChangedFile }
|
||||
|
||||
export interface WorkspaceAccess {
|
||||
getIdentity(): Promise<WorkspaceIdentity>
|
||||
listDirectory(input: WorkspaceDirectoryInput): Promise<DirectoryPage>
|
||||
stat(input: WorkspacePathInput): Promise<WorkspaceEntry>
|
||||
readText(input: WorkspaceReadInput): Promise<FilePreview>
|
||||
writeTextAtomic(input: WorkspaceWriteInput): Promise<WriteResult>
|
||||
search(input: WorkspaceSearchInput): Promise<SearchPage>
|
||||
getChanges(input: WorkspaceChangesInput): Promise<WorkspaceChanges>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
Reference in New Issue
Block a user