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:
mesalogo
2026-08-24 23:02:18 +08:00
parent 3829a871f5
commit 607bb33249
258 changed files with 99449 additions and 2427 deletions
+31
View File
@@ -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.
}
}
+385
View File
@@ -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()
})
})
+100
View File
@@ -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>
}