mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 16:02:25 +00:00
The six oversized src/main/ipc modules each carried a file-level `eslint-disable max-lines` and ran 427-671 counted lines against a 300-line budget. AGENTS.md calls for splitting rather than suppressing, and config/max-lines-baseline.txt is a shrink-only ratchet, so this removes all six suppressions and prunes their entries (341 -> 335). Pure move, no behavior change. Each file is cut along the seams it already had: pet splits into format allowlist / storage paths / symlink-safe copy / bundle manifest + import; filesystem-auth into path-containment primitives, the config-derived allow-list, and the git-registered root cache; notifications into sound selection, native lifecycle, permission probe, and burst cooldown; crash-reporting into renderer error reports, breadcrumbs, and sender. The IPC surface is proved intact rather than assumed: comparing (method, channel) multisets between HEAD and the split gives 49 registrations across 49 distinct channels on both sides. filesystem-auth's security boundary keeps its acyclic layering -- containment primitives, then allow-list, then root cache, then path-resolution orchestration -- with no layer gaining a back-edge. Also keeps clipboard-ipc-handlers.test.ts under the 800-line test budget. The split had briefly added a redundant vi.mock for isENOENT (byte-identical to the real implementation) that pushed it to 801; the mock is dropped in favor of the real function, with realpath added to the existing node:fs/promises mock. Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green (the one remaining failure is a pre-existing load flake in an untouched file, green when re-run serially), no new runtime import cycles among 617 modules, and no lint suppression added anywhere.
2708 lines
91 KiB
TypeScript
2708 lines
91 KiB
TypeScript
/* eslint-disable max-lines -- Why: filesystem, editor-file, and search commands share the same local/SSH path authorization rules. Keeping that IO adapter together prevents separate command paths from drifting on safety checks. */
|
|
import type { ChildProcess } from 'node:child_process'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { watch as watchFs } from 'node:fs'
|
|
import type { FileHandle } from 'node:fs/promises'
|
|
import {
|
|
chmod,
|
|
constants,
|
|
copyFile,
|
|
lstat,
|
|
mkdir,
|
|
open,
|
|
readdir,
|
|
rename,
|
|
realpath,
|
|
rm,
|
|
stat,
|
|
writeFile
|
|
} from 'node:fs/promises'
|
|
import { homedir, tmpdir } from 'node:os'
|
|
import { basename, dirname, extname, join } from 'node:path'
|
|
import type { SearchOptions, SearchResult } from '../../shared/code-search-types'
|
|
import type { DirEntry, FsChangeEvent, MarkdownDocument } from '../../shared/filesystem-entry-types'
|
|
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
|
|
import {
|
|
isPathInsideOrEqual,
|
|
isRuntimePathAbsolute,
|
|
isWindowsAbsolutePathLike,
|
|
normalizeRuntimePathForComparison,
|
|
relativePathInsideRoot,
|
|
resolveRuntimePath
|
|
} from '../../shared/cross-platform-path'
|
|
import {
|
|
REMOTE_RPC_MAX_CONTENT_BYTES,
|
|
remoteRpcResultExceedsContentBudget
|
|
} from '../../shared/remote-rpc-content-budget'
|
|
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
|
|
import { sortDirEntries } from '../../shared/file-name-sort'
|
|
import type {
|
|
RuntimeFileListResult,
|
|
RuntimeFileOpenResult,
|
|
RuntimeFileReadChunkResult,
|
|
RuntimeFilePreviewResult,
|
|
RuntimeFileReadResult,
|
|
RuntimeNativeChatFileContext,
|
|
RuntimeTerminalPathResolution
|
|
} from '../../shared/runtime-types'
|
|
import {
|
|
closeFileExplorerWatcherInWatcherProcess,
|
|
watchFileExplorerInWatcherProcess
|
|
} from './file-watcher-host'
|
|
import { wslAwareSpawn } from '../git/runner'
|
|
import { parseWslPath, toWindowsWslPath } from '../wsl'
|
|
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
|
|
import { isENOENT } from '../ipc/filesystem-path-containment'
|
|
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
|
|
import { searchWithGitGrep } from '../ipc/filesystem-search-git'
|
|
import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options'
|
|
import { checkRgAvailable } from '../ipc/rg-availability'
|
|
import {
|
|
absorbPendingRipgrepSpawnError,
|
|
isRipgrepUnavailableExit,
|
|
killSpawnedRipgrepProcess
|
|
} from '../../shared/ripgrep-process-availability'
|
|
import {
|
|
listMarkdownDocuments,
|
|
markdownDocumentsFromRelativePaths
|
|
} from '../ipc/markdown-documents'
|
|
import {
|
|
buildRgArgs,
|
|
createAccumulator,
|
|
DEFAULT_SEARCH_MAX_RESULTS,
|
|
finalize,
|
|
ingestRgJsonLine,
|
|
SEARCH_TIMEOUT_MS
|
|
} from '../../shared/text-search'
|
|
import type { Store } from '../persistence'
|
|
import {
|
|
getSshFilesystemProvider,
|
|
onSshFilesystemProviderRegistered,
|
|
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
|
} from '../providers/ssh-filesystem-dispatch'
|
|
import type { FileReadLimits, FileStat, IFilesystemProvider } from '../providers/types'
|
|
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
|
|
import {
|
|
isWatcherProcessFailure,
|
|
WatcherProcessFailure
|
|
} from '../ipc/parcel-watcher-process-failure'
|
|
import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
|
import {
|
|
rankRuntimeMobileFilePaths,
|
|
RuntimeMobileFilePathSearchCache
|
|
} from './runtime-mobile-file-path-search'
|
|
import { beginWatcherInstall } from '../ipc/watcher-removal-gate'
|
|
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
|
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
|
import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename'
|
|
import {
|
|
NodeFileReadTooLargeError,
|
|
readNodeFileWithinLimit
|
|
} from '../../shared/node-bounded-file-reader'
|
|
|
|
const MOBILE_FILE_LIST_LIMIT = 5000
|
|
const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000
|
|
const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8
|
|
const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000
|
|
const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024
|
|
const LOCAL_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024
|
|
const PREVIEWABLE_BINARY_EMPTY_RESULT_BYTES = Buffer.byteLength(
|
|
JSON.stringify({
|
|
content: '',
|
|
isBinary: true,
|
|
isImage: true,
|
|
mimeType: 'application/octet-stream'
|
|
}),
|
|
'utf8'
|
|
)
|
|
const PREVIEW_CONTENT_FIELDS = ['content'] as const
|
|
|
|
function previewableBinaryByteLimit(maxContentBytes: number): number {
|
|
const base64Bytes = Math.max(0, maxContentBytes - PREVIEWABLE_BINARY_EMPTY_RESULT_BYTES)
|
|
return Math.floor(base64Bytes / 4) * 3
|
|
}
|
|
|
|
// Why: the stream reader aborts an over-cap read with a raw protocol message; clients key on
|
|
// `file_too_large`, so translate it here rather than surfacing internal stream wording.
|
|
async function readPreviewFileWithinCap(
|
|
provider: IFilesystemProvider,
|
|
filePath: string,
|
|
limits: FileReadLimits
|
|
): Promise<RuntimeFilePreviewResult> {
|
|
try {
|
|
return await provider.readFile(filePath, limits)
|
|
} catch (error) {
|
|
if (error instanceof FileReadCapExceededError) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function assertPreviewWithinTransportBudget(
|
|
result: RuntimeFilePreviewResult,
|
|
maxContentBytes: number | undefined
|
|
): RuntimeFilePreviewResult {
|
|
if (
|
|
maxContentBytes !== undefined &&
|
|
remoteRpcResultExceedsContentBudget(result, maxContentBytes, PREVIEW_CONTENT_FIELDS)
|
|
) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Why: previews are reachable only over RPC and base64 inflates them 4/3, so derive the cap from the
|
|
// transport ceiling — a hardcoded 10 MiB serializes past the outbound envelope and kills the socket.
|
|
export const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = previewableBinaryByteLimit(
|
|
REMOTE_RPC_MAX_CONTENT_BYTES
|
|
)
|
|
const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150
|
|
export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000
|
|
const TERMINAL_FILE_GRANT_TTL_MS = 10 * 60 * 1000
|
|
const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0
|
|
const RUNTIME_FILE_MUTATION_UPDATE_REQUIRED =
|
|
'Remote file changes require a newer Orca client. Update the paired client and try again.'
|
|
|
|
function assertRuntimeFileMutationExpectation(
|
|
connectionId: string | undefined,
|
|
expectedExecutionHostId: string | undefined,
|
|
expectedSshTargetId: string | undefined,
|
|
expectedSshConnectionGeneration: number | undefined
|
|
): void {
|
|
if (!expectedExecutionHostId) {
|
|
throw new Error(RUNTIME_FILE_MUTATION_UPDATE_REQUIRED)
|
|
}
|
|
const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local'
|
|
if (expectedExecutionHostId !== actualExecutionHostId) {
|
|
throw new Error('Workspace host changed; refresh and try again')
|
|
}
|
|
assertSshMutationExpectation(connectionId, expectedSshTargetId, expectedSshConnectionGeneration)
|
|
}
|
|
// Why: files.watch cleanup is synchronous RPC; track native Parcel unsubscribes so shutdown can drain them.
|
|
const pendingRuntimeFileWatcherUnsubscribes = new Set<Promise<void>>()
|
|
|
|
type RuntimeFileWatcherLease = {
|
|
suspend(): Promise<void>
|
|
resume(): Promise<void>
|
|
forget(): void
|
|
}
|
|
const runtimeFileWatcherLeasesByOwnerAndRoot = new Map<string, Set<RuntimeFileWatcherLease>>()
|
|
// Why: the provider's dispose() stops each watch registration without firing its terminal callback,
|
|
// so a dropped SSH transport leaves this watch silently dead — a reconnect's fresh provider is the
|
|
// only signal it can be rebuilt from. Keyed like the leases so worktree removal can drop it.
|
|
const sshFileExplorerWatchRearms = new Map<string, Set<() => void>>()
|
|
const MOBILE_BINARY_EXTENSIONS = new Set([
|
|
'.avif',
|
|
'.bmp',
|
|
'.gif',
|
|
'.heic',
|
|
'.ico',
|
|
'.jpeg',
|
|
'.jpg',
|
|
'.mov',
|
|
'.mp3',
|
|
'.mp4',
|
|
'.pdf',
|
|
'.png',
|
|
'.webp',
|
|
'.zip'
|
|
])
|
|
// Mirror of mobile classifyMobileArtifact's image set; SVG/PDF excluded because RN <Image> can't decode those data URIs.
|
|
const MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS = new Set([
|
|
'.png',
|
|
'.jpg',
|
|
'.jpeg',
|
|
'.gif',
|
|
'.webp',
|
|
'.bmp',
|
|
'.ico'
|
|
])
|
|
|
|
type RuntimeFileStatLike = {
|
|
size?: number
|
|
dev?: number
|
|
ino?: number
|
|
nlink?: number
|
|
mtime?: number | Date
|
|
mtimeMs?: number
|
|
isDirectory?: () => boolean
|
|
}
|
|
|
|
type TerminalFileGrant = {
|
|
id: string
|
|
worktreeId: string
|
|
absolutePath: string
|
|
provider: 'local' | 'ssh'
|
|
connectionId?: string
|
|
clientId?: string
|
|
expiresAt: number
|
|
statIdentity: string | null
|
|
readOnly: boolean
|
|
provenance: 'terminal-output' | 'native-chat'
|
|
expiryTimer?: ReturnType<typeof setTimeout>
|
|
}
|
|
|
|
function isMobilePreviewableImagePath(relativePath: string): boolean {
|
|
const basename = basenameFromRelativePath(relativePath)
|
|
const dotIndex = basename.lastIndexOf('.')
|
|
if (dotIndex <= 0) {
|
|
return false
|
|
}
|
|
return MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase())
|
|
}
|
|
|
|
const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.webp': 'image/webp',
|
|
'.bmp': 'image/bmp',
|
|
'.ico': 'image/x-icon',
|
|
'.pdf': 'application/pdf'
|
|
}
|
|
|
|
function trackRuntimeFileWatcherUnsubscribe(
|
|
rootPath: string,
|
|
unsubscribe: () => Promise<void>
|
|
): Promise<void> {
|
|
const promise = Promise.resolve()
|
|
.then(unsubscribe)
|
|
.finally(() => {
|
|
pendingRuntimeFileWatcherUnsubscribes.delete(promise)
|
|
})
|
|
pendingRuntimeFileWatcherUnsubscribes.add(promise)
|
|
void promise.catch((err: unknown) => {
|
|
console.error('[runtime-files.watch] unsubscribe error', { rootPath, err })
|
|
})
|
|
return promise
|
|
}
|
|
|
|
function normalizeRuntimeWatcherRoot(rootPath: string): string {
|
|
return normalizeRuntimePathForComparison(rootPath)
|
|
}
|
|
|
|
function runtimeWatcherReleaseKey(
|
|
runtimeId: string,
|
|
connectionId: string | undefined,
|
|
rootPath: string
|
|
): string {
|
|
// Why: identical absolute paths exist on local and multiple SSH hosts; scope teardown to the host that owns it.
|
|
return JSON.stringify([runtimeId, connectionId ?? null, normalizeRuntimeWatcherRoot(rootPath)])
|
|
}
|
|
|
|
/**
|
|
* Keep an SSH file-explorer watch alive across reconnects.
|
|
*
|
|
* Why: the previous provider's unwatch handle belongs to the dead transport, so reinstalling on the
|
|
* fresh provider is the only way the subscription comes back. Callers get an overflow because the
|
|
* events lost while the watch was down can't be replayed.
|
|
*/
|
|
function armSshFileExplorerWatchRearm(args: {
|
|
runtimeId: string
|
|
connectionId: string
|
|
rootPath: string
|
|
callback: (events: FsChangeEvent[]) => void
|
|
onTerminalError: (error: Error) => void
|
|
signal?: AbortSignal
|
|
initialUnwatch: () => void
|
|
}): { unsubscribe: () => Promise<void> } {
|
|
const key = runtimeWatcherReleaseKey(args.runtimeId, args.connectionId, args.rootPath)
|
|
let currentUnwatch = args.initialUnwatch
|
|
let stopped = false
|
|
let reinstalling: Promise<void> | null = null
|
|
|
|
const reinstall = async (): Promise<void> => {
|
|
const provider = getSshFilesystemProvider(args.connectionId)
|
|
if (stopped || !provider) {
|
|
return
|
|
}
|
|
// Why: the old handle is scoped to the dead transport; closing it here would only risk
|
|
// unwatching the root we just re-registered on the new one.
|
|
const nextUnwatch = await provider.watch(args.rootPath, args.callback, {
|
|
signal: args.signal,
|
|
onTerminalError: args.onTerminalError
|
|
})
|
|
if (stopped) {
|
|
nextUnwatch()
|
|
return
|
|
}
|
|
currentUnwatch = nextUnwatch
|
|
args.callback([{ kind: 'overflow', absolutePath: args.rootPath }])
|
|
}
|
|
|
|
const unsubscribeRearm = onSshFilesystemProviderRegistered((registeredId) => {
|
|
if (registeredId !== args.connectionId || stopped) {
|
|
return
|
|
}
|
|
// Why: reconnect storms can register repeatedly; chain so a second one can't double-install.
|
|
const attempt = (reinstalling ?? Promise.resolve())
|
|
.then(reinstall)
|
|
.catch((error: unknown) => {
|
|
args.onTerminalError(error instanceof Error ? error : new Error(String(error)))
|
|
})
|
|
.finally(() => {
|
|
if (reinstalling === attempt) {
|
|
reinstalling = null
|
|
}
|
|
})
|
|
reinstalling = attempt
|
|
})
|
|
|
|
const stop = (): void => {
|
|
stopped = true
|
|
unsubscribeRearm()
|
|
const rearms = sshFileExplorerWatchRearms.get(key)
|
|
rearms?.delete(stop)
|
|
if (rearms?.size === 0) {
|
|
sshFileExplorerWatchRearms.delete(key)
|
|
}
|
|
}
|
|
const rearms = sshFileExplorerWatchRearms.get(key) ?? new Set<() => void>()
|
|
rearms.add(stop)
|
|
sshFileExplorerWatchRearms.set(key, rearms)
|
|
|
|
return {
|
|
unsubscribe: () => {
|
|
stop()
|
|
const close = async (): Promise<void> => currentUnwatch()
|
|
// Why: awaiting an absent reinstall costs a microtask, and removal gating relies on the
|
|
// unwatch being issued on the same turn the lease releases it.
|
|
return reinstalling ? reinstalling.catch(() => undefined).then(close) : close()
|
|
}
|
|
}
|
|
}
|
|
|
|
function stopSshFileExplorerWatchRearms(key: string): void {
|
|
for (const stop of Array.from(sshFileExplorerWatchRearms.get(key) ?? [])) {
|
|
stop()
|
|
}
|
|
}
|
|
|
|
function registerRuntimeFileWatcherRelease(
|
|
runtimeId: string,
|
|
connectionId: string | undefined,
|
|
rootPaths: string[],
|
|
unsubscribe: () => Promise<void>,
|
|
restart: () => Promise<() => Promise<void>>,
|
|
onRestoreError: (error: Error) => void
|
|
): () => Promise<void> {
|
|
const keys = Array.from(
|
|
new Set(
|
|
rootPaths.map((rootPath) => runtimeWatcherReleaseKey(runtimeId, connectionId, rootPath))
|
|
)
|
|
)
|
|
let currentUnsubscribe: (() => Promise<void>) | null = unsubscribe
|
|
let releasePromise: Promise<void> | null = null
|
|
let physicalExitPromise: Promise<void> | null = null
|
|
let resumePromise: Promise<void> | null = null
|
|
let stopPromise: Promise<void> | null = null
|
|
let logicallyStopped = false
|
|
const removeLease = (): void => {
|
|
for (const key of keys) {
|
|
const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key)
|
|
leases?.delete(lease)
|
|
if (leases?.size === 0) {
|
|
runtimeFileWatcherLeasesByOwnerAndRoot.delete(key)
|
|
}
|
|
}
|
|
}
|
|
const suspend = (): Promise<void> => {
|
|
if (releasePromise) {
|
|
return releasePromise
|
|
}
|
|
const release = currentUnsubscribe
|
|
if (!release) {
|
|
return Promise.resolve()
|
|
}
|
|
const attempt = trackRuntimeFileWatcherUnsubscribe(rootPaths[0], release)
|
|
releasePromise = attempt
|
|
void attempt.then(
|
|
() => {
|
|
if (currentUnsubscribe === release) {
|
|
currentUnsubscribe = null
|
|
}
|
|
releasePromise = null
|
|
},
|
|
(error: unknown) => {
|
|
if (isWatcherProcessFailure(error) && error.physicalExit) {
|
|
const physicalExit = error.physicalExit.then(() => {
|
|
if (currentUnsubscribe === release) {
|
|
currentUnsubscribe = null
|
|
}
|
|
releasePromise = null
|
|
if (physicalExitPromise === physicalExit) {
|
|
physicalExitPromise = null
|
|
}
|
|
if (logicallyStopped) {
|
|
removeLease()
|
|
}
|
|
})
|
|
physicalExitPromise = physicalExit
|
|
} else {
|
|
// Why: a synchronous close failure retains the native owner so a later removal or unsubscribe can retry the same handle.
|
|
releasePromise = null
|
|
}
|
|
}
|
|
)
|
|
return attempt
|
|
}
|
|
const lease: RuntimeFileWatcherLease = {
|
|
suspend,
|
|
resume: () => {
|
|
if (logicallyStopped || (currentUnsubscribe && !physicalExitPromise)) {
|
|
return Promise.resolve()
|
|
}
|
|
if (resumePromise) {
|
|
return physicalExitPromise ? Promise.resolve() : resumePromise
|
|
}
|
|
// Why: a timed-out child still owns native handles until physical exit; join that owner before starting a replacement.
|
|
const resumesAfterPhysicalExit = physicalExitPromise !== null
|
|
const attempt = Promise.resolve(physicalExitPromise ?? releasePromise)
|
|
.then(async () => {
|
|
if (logicallyStopped) {
|
|
return
|
|
}
|
|
const nextUnsubscribe = await restart()
|
|
if (logicallyStopped) {
|
|
await nextUnsubscribe()
|
|
return
|
|
}
|
|
currentUnsubscribe = nextUnsubscribe
|
|
})
|
|
.catch((error: unknown) => {
|
|
const restoreError = error instanceof Error ? error : new Error(String(error))
|
|
queueMicrotask(() => onRestoreError(restoreError))
|
|
throw restoreError
|
|
})
|
|
.finally(() => {
|
|
resumePromise = null
|
|
})
|
|
resumePromise = attempt
|
|
if (resumesAfterPhysicalExit) {
|
|
void attempt.catch(() => {})
|
|
return Promise.resolve()
|
|
}
|
|
return attempt
|
|
},
|
|
forget: () => {
|
|
logicallyStopped = true
|
|
removeLease()
|
|
}
|
|
}
|
|
for (const key of keys) {
|
|
const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) ?? new Set()
|
|
leases.add(lease)
|
|
runtimeFileWatcherLeasesByOwnerAndRoot.set(key, leases)
|
|
}
|
|
return () => {
|
|
if (stopPromise) {
|
|
return stopPromise
|
|
}
|
|
logicallyStopped = true
|
|
const release =
|
|
resumePromise && !physicalExitPromise
|
|
? Promise.resolve(resumePromise)
|
|
.catch(() => undefined)
|
|
.then(suspend)
|
|
: suspend()
|
|
const attempt = release.then(removeLease).catch((error: unknown) => {
|
|
stopPromise = null
|
|
throw error
|
|
})
|
|
stopPromise = attempt
|
|
return attempt
|
|
}
|
|
}
|
|
|
|
export async function awaitRuntimeFileWatcherUnsubscribes(): Promise<void> {
|
|
await Promise.allSettled(Array.from(pendingRuntimeFileWatcherUnsubscribes))
|
|
}
|
|
|
|
export function _getRuntimeFileWatcherReleaseCountForTests(): number {
|
|
const leases = new Set<RuntimeFileWatcherLease>()
|
|
for (const rootLeases of runtimeFileWatcherLeasesByOwnerAndRoot.values()) {
|
|
for (const lease of rootLeases) {
|
|
leases.add(lease)
|
|
}
|
|
}
|
|
return leases.size
|
|
}
|
|
|
|
export function _resetRuntimeFileWatcherLeasesForTests(): void {
|
|
const leases = new Set<RuntimeFileWatcherLease>()
|
|
for (const rootLeases of runtimeFileWatcherLeasesByOwnerAndRoot.values()) {
|
|
for (const lease of rootLeases) {
|
|
leases.add(lease)
|
|
}
|
|
}
|
|
for (const lease of leases) {
|
|
lease.forget()
|
|
}
|
|
for (const key of Array.from(sshFileExplorerWatchRearms.keys())) {
|
|
stopSshFileExplorerWatchRearms(key)
|
|
}
|
|
runtimeFileWatcherLeasesByOwnerAndRoot.clear()
|
|
}
|
|
|
|
export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo }
|
|
export type ResolvedRuntimeFileTarget = {
|
|
worktree: ResolvedRuntimeFileWorktree
|
|
connectionId?: string
|
|
}
|
|
|
|
export function getRuntimeFileTargetExecutionHostId(
|
|
target: ResolvedRuntimeFileTarget
|
|
): ExecutionHostId {
|
|
return (
|
|
target.worktree.hostId ??
|
|
(target.connectionId ? toSshExecutionHostId(target.connectionId) : 'local')
|
|
)
|
|
}
|
|
|
|
export type RuntimeFileCommandHost = {
|
|
getRuntimeId(): string
|
|
requireStore(): Store
|
|
resolveWorktreeSelector(selector: string): Promise<ResolvedRuntimeFileWorktree>
|
|
resolveRuntimeFileTarget(selector: string): Promise<ResolvedRuntimeFileTarget>
|
|
resolveKnownWorkspaceFileTarget?(
|
|
absolutePath: string,
|
|
executionHostId: ExecutionHostId
|
|
): Promise<(ResolvedRuntimeFileTarget & { relativePath: string }) | null>
|
|
resolveTerminalCwd?(terminalHandle: string): string | null | Promise<string | null>
|
|
resolveTerminalContext?(
|
|
terminalHandle: string
|
|
): { worktreeId: string; connectionId: string | null } | null
|
|
resolveTerminalFileUriHostname?(terminalHandle: string): string | null | Promise<string | null>
|
|
hasRecentTerminalOutputPath?(
|
|
terminalHandle: string,
|
|
pathText: string,
|
|
absolutePath: string
|
|
): boolean | Promise<boolean>
|
|
hasRecentNativeChatOutputPath?(
|
|
worktreeId: string,
|
|
context: RuntimeNativeChatFileContext,
|
|
pathText: string,
|
|
absolutePath: string
|
|
): boolean | Promise<boolean>
|
|
resolveRuntimeGitTarget(
|
|
selector: string
|
|
): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }>
|
|
openFile(
|
|
worktreeId: string,
|
|
filePath: string,
|
|
relativePath: string,
|
|
runtimeEnvironmentId?: string | null
|
|
): void
|
|
openDiff(
|
|
worktreeId: string,
|
|
filePath: string,
|
|
relativePath: string,
|
|
staged: boolean,
|
|
runtimeEnvironmentId?: string | null
|
|
): void
|
|
}
|
|
|
|
export class RuntimeFileCommands {
|
|
private activeRuntimeTextSearches = new Map<string, ChildProcess>()
|
|
private terminalFileGrants = new Map<string, TerminalFileGrant>()
|
|
private mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache(
|
|
MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES,
|
|
MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS
|
|
)
|
|
|
|
constructor(private readonly host: RuntimeFileCommandHost) {}
|
|
|
|
async listMobileFiles(worktreeSelector: string): Promise<RuntimeFileListResult> {
|
|
const store = this.host.requireStore()
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
const { worktree, connectionId } = target
|
|
const files = connectionId
|
|
? await this.listRemoteMobileFiles(worktree.path, connectionId)
|
|
: await listQuickOpenFiles(worktree.path, store)
|
|
const entries = files
|
|
.filter((relativePath) => isSafeMobileRelativePath(relativePath))
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.slice(0, MOBILE_FILE_LIST_LIMIT)
|
|
.map((relativePath) => ({
|
|
relativePath,
|
|
basename: basenameFromRelativePath(relativePath),
|
|
kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const)
|
|
}))
|
|
|
|
return {
|
|
worktree: worktree.id,
|
|
rootPath: worktree.path,
|
|
files: entries,
|
|
totalCount: files.length,
|
|
truncated: files.length > MOBILE_FILE_LIST_LIMIT
|
|
}
|
|
}
|
|
|
|
async searchMobileFilePaths(
|
|
worktreeSelector: string,
|
|
query: string,
|
|
limit: number
|
|
): Promise<RuntimeFileListResult> {
|
|
const store = this.host.requireStore()
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
const { worktree, connectionId } = target
|
|
const cacheKey = `${connectionId ?? 'local'}:${worktree.id}:${worktree.path}`
|
|
const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => {
|
|
const listed = connectionId
|
|
? await this.listRemoteMobileFiles(
|
|
worktree.path,
|
|
connectionId,
|
|
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
|
|
)
|
|
: await listQuickOpenFiles(
|
|
worktree.path,
|
|
store,
|
|
undefined,
|
|
undefined,
|
|
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
|
|
)
|
|
const safePaths = listed
|
|
.filter((relativePath) => isSafeMobileRelativePath(relativePath))
|
|
.sort((a, b) => a.localeCompare(b))
|
|
return {
|
|
paths: safePaths.slice(0, MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT),
|
|
totalCount: safePaths.length,
|
|
truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT
|
|
}
|
|
})
|
|
const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit)
|
|
return {
|
|
worktree: worktree.id,
|
|
rootPath: worktree.path,
|
|
files: matches.paths.map((relativePath) => ({
|
|
relativePath,
|
|
basename: basenameFromRelativePath(relativePath),
|
|
kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const)
|
|
})),
|
|
totalCount: matches.totalCount,
|
|
truncated: inventory.truncated || matches.totalCount > limit
|
|
}
|
|
}
|
|
|
|
async openMobileFile(
|
|
worktreeSelector: string,
|
|
relativePath: string
|
|
): Promise<RuntimeFileOpenResult> {
|
|
const { worktree, connectionId } = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
if (!isSafeMobileRelativePath(relativePath)) {
|
|
throw new Error('invalid_relative_path')
|
|
}
|
|
// Previewable images open like text (mobile renders via files.readPreview); other binaries stay unavailable on mobile.
|
|
const kind = isMobilePreviewableImagePath(relativePath)
|
|
? 'image'
|
|
: isMobileBinaryPath(relativePath)
|
|
? 'binary'
|
|
: isMobileMarkdownPath(relativePath)
|
|
? 'markdown'
|
|
: 'text'
|
|
if (kind === 'binary') {
|
|
return { worktree: worktree.id, relativePath, kind, opened: false }
|
|
}
|
|
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
|
|
// Why: CLI/agents treat opened:true as success; stat first so missing paths fail the RPC instead of opening a ghost tab.
|
|
await this.assertMobileOpenTargetExists(filePath, connectionId)
|
|
// Why: the internal runtimeId isn't a valid env selector; pass undefined so openFile falls back to activeRuntimeEnvironmentId.
|
|
this.host.openFile(worktree.id, filePath, relativePath, undefined)
|
|
return { worktree: worktree.id, relativePath, kind, opened: true }
|
|
}
|
|
|
|
private async assertMobileOpenTargetExists(
|
|
filePath: string,
|
|
connectionId?: string
|
|
): Promise<void> {
|
|
try {
|
|
await (connectionId
|
|
? this.statRemoteTerminalPath(filePath, connectionId)
|
|
: stat(await resolveAuthorizedPath(filePath, this.host.requireStore())))
|
|
} catch (error) {
|
|
if (
|
|
isENOENT(error) ||
|
|
(connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
|
|
) {
|
|
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async openMobileDiff(
|
|
worktreeSelector: string,
|
|
relativePath: string,
|
|
staged: boolean
|
|
): Promise<RuntimeFileOpenResult> {
|
|
const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
if (!isSafeMobileRelativePath(relativePath)) {
|
|
throw new Error('invalid_relative_path')
|
|
}
|
|
const kind = isMobileBinaryPath(relativePath)
|
|
? 'binary'
|
|
: isMobileMarkdownPath(relativePath)
|
|
? 'markdown'
|
|
: 'text'
|
|
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
|
|
// Why: see openMobileFile; avoid stamping internal runtimeId as runtimeEnvironmentId.
|
|
this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined)
|
|
return { worktree: worktree.id, relativePath, kind, opened: true }
|
|
}
|
|
|
|
async readMobileFile(
|
|
worktreeSelector: string,
|
|
relativePath: string
|
|
): Promise<RuntimeFileReadResult> {
|
|
const store = this.host.requireStore()
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
const { worktree, connectionId } = target
|
|
if (!isSafeMobileRelativePath(relativePath)) {
|
|
throw new Error('invalid_relative_path')
|
|
}
|
|
if (isMobileBinaryPath(relativePath)) {
|
|
throw new Error('binary_file')
|
|
}
|
|
|
|
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
|
|
const content = connectionId
|
|
? await this.readRemoteMobileFile(filePath, connectionId)
|
|
: await readLocalMobileFile(filePath, store)
|
|
const truncated = truncateMobileFilePreview(content)
|
|
|
|
return {
|
|
worktree: worktree.id,
|
|
relativePath,
|
|
content: truncated.content,
|
|
truncated: truncated.truncated,
|
|
byteLength: truncated.byteLength
|
|
}
|
|
}
|
|
|
|
// Resolves a mobile terminal tap to a worktree-relative path; relatives resolve against cwd, else the worktree root.
|
|
async resolveTerminalPath(
|
|
worktreeSelector: string,
|
|
pathText: string,
|
|
cwd?: string | null,
|
|
clientId?: string,
|
|
terminalHandle?: string | null,
|
|
crossWorkspace?: boolean,
|
|
nativeChatContext?: RuntimeNativeChatFileContext | null
|
|
): Promise<RuntimeTerminalPathResolution> {
|
|
const store = this.host.requireStore()
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
const { worktree, connectionId } = target
|
|
// Why: mobile may attach after OSC7 cwd was emitted; the runtime still owns the terminal's latest cwd to resolve the tap.
|
|
const normalizedTerminalHandle =
|
|
terminalHandle && terminalHandle.trim().length > 0 ? terminalHandle.trim() : null
|
|
const terminalCwd = normalizedTerminalHandle
|
|
? await this.host.resolveTerminalCwd?.(normalizedTerminalHandle)
|
|
: null
|
|
const terminalFileUriHostname = normalizedTerminalHandle
|
|
? await this.host.resolveTerminalFileUriHostname?.(normalizedTerminalHandle)
|
|
: null
|
|
const base = terminalCwd || (cwd && cwd.trim().length > 0 ? cwd : worktree.path)
|
|
|
|
const empty: RuntimeTerminalPathResolution = {
|
|
worktree: worktree.id,
|
|
relativePath: null,
|
|
absolutePath: null,
|
|
exists: false,
|
|
isDirectory: false
|
|
}
|
|
|
|
// Why: SSH/WSL homes are unknown here; native-chat grants must not expand their ~/… paths against the local host home.
|
|
const isTilde = pathText.startsWith('~/') || pathText.startsWith('~\\')
|
|
if (isTilde && (connectionId || (nativeChatContext && parseWslPath(worktree.path)))) {
|
|
return empty
|
|
}
|
|
const expanded = isTilde ? resolveRuntimePath(homedir(), pathText.slice(2)) : pathText
|
|
const absolutePath = resolveTerminalAbsolutePath({
|
|
base,
|
|
expanded,
|
|
worktreePath: worktree.path,
|
|
connectionId,
|
|
terminalFileUriHostname
|
|
})
|
|
const relativePath = relativePathInsideRoot(worktree.path, absolutePath)
|
|
// Why: clients that predate crossWorkspace reuse their own worktree id for the
|
|
// follow-up files.open, so retargeting to a sibling workspace must be opt-in.
|
|
const knownWorkspaceTarget =
|
|
crossWorkspace && relativePath === null
|
|
? await this.host.resolveKnownWorkspaceFileTarget?.(
|
|
absolutePath,
|
|
getRuntimeFileTargetExecutionHostId(target)
|
|
)
|
|
: null
|
|
const ownedWorktree = knownWorkspaceTarget?.worktree ?? worktree
|
|
const ownedConnectionId = knownWorkspaceTarget?.connectionId ?? connectionId
|
|
const ownedRelativePath = knownWorkspaceTarget?.relativePath ?? relativePath
|
|
|
|
try {
|
|
if (
|
|
ownedRelativePath !== null &&
|
|
(ownedRelativePath === '' || isSafeMobileRelativePath(ownedRelativePath))
|
|
) {
|
|
const stats = ownedConnectionId
|
|
? await this.statRemoteTerminalPath(absolutePath, ownedConnectionId)
|
|
: await stat(await resolveAuthorizedPath(absolutePath, store))
|
|
return {
|
|
worktree: ownedWorktree.id,
|
|
relativePath: ownedRelativePath,
|
|
absolutePath,
|
|
exists: true,
|
|
isDirectory: stats.isDirectory(),
|
|
openTarget: stats.isDirectory()
|
|
? undefined
|
|
: {
|
|
kind: 'worktree-file',
|
|
provider: ownedConnectionId ? 'ssh' : 'local',
|
|
relativePath: ownedRelativePath,
|
|
absolutePath
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
nativeChatContext &&
|
|
(await this.host.hasRecentNativeChatOutputPath?.(
|
|
worktree.id,
|
|
nativeChatContext,
|
|
pathText,
|
|
absolutePath
|
|
))
|
|
) {
|
|
const artifactPath = await this.resolveNativeChatArtifactPath(absolutePath, connectionId)
|
|
return await this.resolveAbsoluteFileGrant({
|
|
worktreeId: worktree.id,
|
|
artifactPath,
|
|
connectionId,
|
|
clientId,
|
|
readOnly: true,
|
|
provenance: 'native-chat'
|
|
})
|
|
}
|
|
|
|
// Why: mobile taps may hit agent artifacts outside the worktree; grant the exact path, not arbitrary absolute paths.
|
|
if (!normalizedTerminalHandle || !terminalCwd) {
|
|
return { ...empty, relativePath, absolutePath }
|
|
}
|
|
const terminalContext = this.host.resolveTerminalContext?.(normalizedTerminalHandle)
|
|
if (
|
|
!terminalContext ||
|
|
terminalContext.worktreeId !== worktree.id ||
|
|
(terminalContext.connectionId ?? undefined) !== connectionId
|
|
) {
|
|
return { ...empty, relativePath, absolutePath }
|
|
}
|
|
const artifactPath = await this.resolveAllowedTerminalArtifactPath({
|
|
absolutePath,
|
|
connectionId,
|
|
worktreePath: worktree.path
|
|
})
|
|
if (!artifactPath) {
|
|
return { ...empty, relativePath, absolutePath }
|
|
}
|
|
if (
|
|
!(await this.host.hasRecentTerminalOutputPath?.(
|
|
normalizedTerminalHandle,
|
|
provenancePathCandidate(pathText, absolutePath),
|
|
artifactPath
|
|
))
|
|
) {
|
|
return { ...empty, relativePath, absolutePath }
|
|
}
|
|
return await this.resolveAbsoluteFileGrant({
|
|
worktreeId: worktree.id,
|
|
artifactPath,
|
|
rejectedAbsolutePath: absolutePath,
|
|
connectionId,
|
|
clientId
|
|
})
|
|
} catch (error) {
|
|
// Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing.
|
|
if (
|
|
isENOENT(error) ||
|
|
(ownedConnectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
|
|
) {
|
|
return {
|
|
...empty,
|
|
worktree: ownedWorktree.id,
|
|
relativePath: ownedRelativePath,
|
|
absolutePath
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// The mux drops ErrnoException.code, so match not-found by message shape (vs transport/permission/provider errors).
|
|
private static isRemoteNotFoundErrorMessage(error: unknown): boolean {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return /\bENOENT\b|no such file|not found|does not exist/i.test(message)
|
|
}
|
|
|
|
private async statRemoteTerminalPath(
|
|
absolutePath: string,
|
|
connectionId: string
|
|
): Promise<RuntimeFileStatLike & { isDirectory: () => boolean }> {
|
|
const provider = getSshFilesystemProvider(connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
const stats = await provider.stat(absolutePath)
|
|
return { ...stats, isDirectory: () => stats.type === 'directory' }
|
|
}
|
|
|
|
private async resolveAllowedTerminalArtifactPath(args: {
|
|
absolutePath: string
|
|
connectionId?: string
|
|
worktreePath: string
|
|
}): Promise<string | null> {
|
|
if (args.connectionId) {
|
|
return this.resolveAllowedRemoteTerminalArtifactPath(args.absolutePath, args.connectionId)
|
|
}
|
|
return resolveAllowedLocalTerminalArtifactPath(args.absolutePath, args.worktreePath)
|
|
}
|
|
|
|
private async resolveNativeChatArtifactPath(
|
|
absolutePath: string,
|
|
connectionId?: string
|
|
): Promise<string> {
|
|
if (!connectionId) {
|
|
return canonicalPathForArtifactComparison(absolutePath)
|
|
}
|
|
const provider = getSshFilesystemProvider(connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.realpath(absolutePath)
|
|
}
|
|
|
|
private async resolveAbsoluteFileGrant(args: {
|
|
worktreeId: string
|
|
artifactPath: string
|
|
rejectedAbsolutePath?: string
|
|
connectionId?: string
|
|
clientId?: string
|
|
readOnly?: boolean
|
|
provenance?: TerminalFileGrant['provenance']
|
|
}): Promise<RuntimeTerminalPathResolution> {
|
|
const stats = args.connectionId
|
|
? await this.statRemoteTerminalPath(args.artifactPath, args.connectionId)
|
|
: await this.statLocalTerminalPath(args.artifactPath)
|
|
const isDirectory = stats.isDirectory()
|
|
if (!isDirectory && isTerminalArtifactHardLinked(stats)) {
|
|
return {
|
|
worktree: args.worktreeId,
|
|
relativePath: null,
|
|
absolutePath: args.rejectedAbsolutePath ?? args.artifactPath,
|
|
exists: false,
|
|
isDirectory: false
|
|
}
|
|
}
|
|
const grant = isDirectory
|
|
? null
|
|
: this.createTerminalFileGrant({
|
|
worktreeId: args.worktreeId,
|
|
absolutePath: args.artifactPath,
|
|
provider: args.connectionId ? 'ssh' : 'local',
|
|
connectionId: args.connectionId,
|
|
clientId: args.clientId,
|
|
readOnly: args.readOnly === true,
|
|
provenance: args.provenance ?? 'terminal-output',
|
|
stats
|
|
})
|
|
return {
|
|
worktree: args.worktreeId,
|
|
relativePath: null,
|
|
absolutePath: args.artifactPath,
|
|
exists: true,
|
|
isDirectory,
|
|
openTarget: grant
|
|
? {
|
|
kind: 'absolute-file',
|
|
provider: grant.provider,
|
|
absolutePath: args.artifactPath,
|
|
grantId: grant.id,
|
|
...(grant.readOnly ? { readOnly: true } : {})
|
|
}
|
|
: undefined
|
|
}
|
|
}
|
|
|
|
private async resolveAllowedRemoteTerminalArtifactPath(
|
|
absolutePath: string,
|
|
connectionId: string
|
|
): Promise<string | null> {
|
|
const provider = getSshFilesystemProvider(connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
const roots = ['/tmp', '/private/tmp']
|
|
const providerTempDir = await provider.getTempDir?.().catch(() => null)
|
|
if (providerTempDir) {
|
|
roots.push(providerTempDir)
|
|
}
|
|
if (!roots.some((root) => isPathInsideOrEqual(root, absolutePath))) {
|
|
return null
|
|
}
|
|
const [realArtifactPath, ...realRoots] = await Promise.all([
|
|
provider.realpath(absolutePath),
|
|
...roots.map((root) => provider.realpath(root).catch(() => root))
|
|
])
|
|
// Why: SSH I/O follows symlinks on the relay; grant the canonical target so a /tmp link can't escape the temp boundary.
|
|
return realRoots.some((root) => isPathInsideOrEqual(root, realArtifactPath))
|
|
? realArtifactPath
|
|
: null
|
|
}
|
|
|
|
private async statLocalTerminalPath(
|
|
absolutePath: string
|
|
): Promise<RuntimeFileStatLike & { isDirectory: () => boolean }> {
|
|
await assertLocalTerminalArtifactPathStillCanonical(absolutePath)
|
|
const handle = await open(absolutePath, 'r')
|
|
try {
|
|
return handle.stat()
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
}
|
|
|
|
private createTerminalFileGrant(args: {
|
|
worktreeId: string
|
|
absolutePath: string
|
|
provider: 'local' | 'ssh'
|
|
connectionId?: string
|
|
clientId?: string
|
|
readOnly?: boolean
|
|
provenance: TerminalFileGrant['provenance']
|
|
stats: RuntimeFileStatLike
|
|
}): TerminalFileGrant {
|
|
assertTerminalArtifactNotHardLinked(args.stats)
|
|
const grant: TerminalFileGrant = {
|
|
id: randomUUID(),
|
|
worktreeId: args.worktreeId,
|
|
absolutePath: args.absolutePath,
|
|
provider: args.provider,
|
|
...(args.connectionId ? { connectionId: args.connectionId } : {}),
|
|
...(args.clientId ? { clientId: args.clientId } : {}),
|
|
expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS,
|
|
statIdentity: terminalFileStatIdentity(args.stats),
|
|
readOnly: args.readOnly === true,
|
|
provenance: args.provenance
|
|
}
|
|
this.terminalFileGrants.set(grant.id, grant)
|
|
this.scheduleTerminalFileGrantExpiry(grant)
|
|
return grant
|
|
}
|
|
|
|
private async requireTerminalFileGrant(
|
|
worktreeSelector: string,
|
|
grantId: string,
|
|
absolutePath: string,
|
|
clientId?: string
|
|
): Promise<{ grant: TerminalFileGrant; target: ResolvedRuntimeFileTarget }> {
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
this.pruneExpiredTerminalFileGrants()
|
|
const grant = this.terminalFileGrants.get(grantId)
|
|
if (!grant) {
|
|
throw new Error('terminal_file_grant_expired')
|
|
}
|
|
if (grant.expiresAt <= Date.now()) {
|
|
this.releaseTerminalFileGrant(grantId, grant)
|
|
throw new Error('terminal_file_grant_expired')
|
|
}
|
|
if (
|
|
grant.worktreeId !== target.worktree.id ||
|
|
grant.absolutePath !== absolutePath ||
|
|
grant.connectionId !== target.connectionId ||
|
|
grant.clientId !== clientId
|
|
) {
|
|
throw new Error('terminal_file_grant_mismatch')
|
|
}
|
|
return { grant, target }
|
|
}
|
|
|
|
private refreshTerminalFileGrant(grant: TerminalFileGrant): void {
|
|
grant.expiresAt = Date.now() + TERMINAL_FILE_GRANT_TTL_MS
|
|
this.scheduleTerminalFileGrantExpiry(grant)
|
|
}
|
|
|
|
private pruneExpiredTerminalFileGrants(): void {
|
|
const now = Date.now()
|
|
for (const [id, grant] of this.terminalFileGrants) {
|
|
if (grant.expiresAt <= now) {
|
|
this.releaseTerminalFileGrant(id, grant)
|
|
}
|
|
}
|
|
}
|
|
|
|
revokeTerminalFileGrantsForClient(clientId: string): void {
|
|
for (const [id, grant] of this.terminalFileGrants) {
|
|
if (grant.clientId === clientId) {
|
|
this.releaseTerminalFileGrant(id, grant)
|
|
}
|
|
}
|
|
}
|
|
|
|
private releaseTerminalFileGrant(id: string, grant: TerminalFileGrant): void {
|
|
this.terminalFileGrants.delete(id)
|
|
if (grant.expiryTimer) {
|
|
clearTimeout(grant.expiryTimer)
|
|
grant.expiryTimer = undefined
|
|
}
|
|
}
|
|
|
|
private scheduleTerminalFileGrantExpiry(grant: TerminalFileGrant): void {
|
|
if (grant.expiryTimer) {
|
|
clearTimeout(grant.expiryTimer)
|
|
}
|
|
grant.expiryTimer = setTimeout(
|
|
() => {
|
|
if (this.terminalFileGrants.get(grant.id) === grant && grant.expiresAt <= Date.now()) {
|
|
this.releaseTerminalFileGrant(grant.id, grant)
|
|
}
|
|
},
|
|
Math.max(1, grant.expiresAt - Date.now())
|
|
)
|
|
grant.expiryTimer.unref?.()
|
|
}
|
|
|
|
async readTerminalArtifactFile(
|
|
worktreeSelector: string,
|
|
grantId: string,
|
|
absolutePath: string,
|
|
clientId?: string
|
|
): Promise<RuntimeFileReadResult> {
|
|
const { grant, target } = await this.requireTerminalFileGrant(
|
|
worktreeSelector,
|
|
grantId,
|
|
absolutePath,
|
|
clientId
|
|
)
|
|
if (isMobileBinaryPath(grant.absolutePath)) {
|
|
throw new Error('binary_file')
|
|
}
|
|
let content: string
|
|
if (grant.connectionId) {
|
|
const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant)
|
|
content = await this.readRemoteTerminalArtifactFile(
|
|
provider,
|
|
grant,
|
|
MOBILE_FILE_READ_MAX_BYTES
|
|
)
|
|
} else {
|
|
const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY)
|
|
try {
|
|
content = await readLocalTerminalArtifactFileFromHandle(handle, grant)
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
}
|
|
this.refreshTerminalFileGrant(grant)
|
|
const truncated = truncateMobileFilePreview(content)
|
|
|
|
return {
|
|
worktree: target.worktree.id,
|
|
relativePath: grant.absolutePath,
|
|
content: truncated.content,
|
|
truncated: truncated.truncated,
|
|
byteLength: truncated.byteLength
|
|
}
|
|
}
|
|
|
|
async readTerminalArtifactPreview(
|
|
worktreeSelector: string,
|
|
grantId: string,
|
|
absolutePath: string,
|
|
clientId?: string,
|
|
maxContentBytes?: number
|
|
): Promise<RuntimeFilePreviewResult> {
|
|
const { grant } = await this.requireTerminalFileGrant(
|
|
worktreeSelector,
|
|
grantId,
|
|
absolutePath,
|
|
clientId
|
|
)
|
|
if (grant.connectionId) {
|
|
const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant)
|
|
this.refreshTerminalFileGrant(grant)
|
|
return assertPreviewWithinTransportBudget(
|
|
await this.readRemoteTerminalArtifactPreview(provider, grant, maxContentBytes),
|
|
maxContentBytes
|
|
)
|
|
}
|
|
const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY)
|
|
try {
|
|
const preview = await readLocalTerminalArtifactPreviewFromHandle(
|
|
handle,
|
|
grant,
|
|
maxContentBytes
|
|
)
|
|
this.refreshTerminalFileGrant(grant)
|
|
return assertPreviewWithinTransportBudget(preview, maxContentBytes)
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
}
|
|
|
|
async writeTerminalArtifactFile(
|
|
worktreeSelector: string,
|
|
grantId: string,
|
|
absolutePath: string,
|
|
content: string,
|
|
clientId?: string
|
|
): Promise<{ ok: true }> {
|
|
if (Buffer.byteLength(content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
const { grant } = await this.requireTerminalFileGrant(
|
|
worktreeSelector,
|
|
grantId,
|
|
absolutePath,
|
|
clientId
|
|
)
|
|
if (grant.readOnly) {
|
|
throw new Error('terminal_file_grant_read_only')
|
|
}
|
|
if (isMobileBinaryPath(grant.absolutePath)) {
|
|
throw new Error('binary_file')
|
|
}
|
|
if (grant.connectionId) {
|
|
const { provider, fileStat } = await this.assertRemoteTerminalFileGrantFresh(grant)
|
|
if (fileStat.type === 'directory') {
|
|
throw new Error('Cannot write to a directory')
|
|
}
|
|
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
if (!provider.writeTerminalArtifact) {
|
|
throw new Error('terminal_file_grant_unavailable')
|
|
}
|
|
const nextStat = await provider.writeTerminalArtifact(
|
|
grant.absolutePath,
|
|
content,
|
|
this.terminalArtifactAccessOptions(grant, MOBILE_FILE_READ_MAX_BYTES)
|
|
)
|
|
grant.statIdentity = terminalFileStatIdentity(nextStat)
|
|
this.refreshTerminalFileGrant(grant)
|
|
return { ok: true }
|
|
}
|
|
|
|
let originalMode: number | null = null
|
|
const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY)
|
|
try {
|
|
const fileStats = await handle.stat()
|
|
originalMode = fileStats.mode
|
|
if (fileStats.isDirectory()) {
|
|
throw new Error('Cannot write to a directory')
|
|
}
|
|
if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
assertTerminalFileGrantFresh(grant, fileStats)
|
|
if (
|
|
isBinaryBuffer(await readFileHandleBufferBounded(handle, MOBILE_FILE_READ_MAX_BYTES + 1))
|
|
) {
|
|
throw new Error('binary_file')
|
|
}
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
const tempPath = join(
|
|
dirname(grant.absolutePath),
|
|
`.${basename(grant.absolutePath)}.${randomUUID()}.tmp`
|
|
)
|
|
try {
|
|
await writeFile(tempPath, content, { encoding: 'utf-8', flag: 'wx' })
|
|
if (typeof originalMode === 'number') {
|
|
await chmod(tempPath, originalMode & 0o7777)
|
|
}
|
|
const freshHandle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY)
|
|
try {
|
|
assertTerminalFileGrantFresh(grant, await freshHandle.stat())
|
|
} finally {
|
|
await freshHandle.close()
|
|
}
|
|
await rename(tempPath, grant.absolutePath)
|
|
grant.statIdentity = terminalFileStatIdentity(
|
|
await this.statLocalTerminalPath(grant.absolutePath)
|
|
)
|
|
this.refreshTerminalFileGrant(grant)
|
|
return { ok: true }
|
|
} finally {
|
|
await rm(tempPath, { force: true }).catch(() => {})
|
|
}
|
|
}
|
|
|
|
private async readRemoteTerminalArtifactPreview(
|
|
provider: IFilesystemProvider,
|
|
grant: TerminalFileGrant,
|
|
maxContentBytes: number | undefined
|
|
): Promise<RuntimeFilePreviewResult> {
|
|
const binaryMaxBytes =
|
|
maxContentBytes === undefined
|
|
? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES
|
|
: previewableBinaryByteLimit(maxContentBytes)
|
|
const preview = await this.readRemoteTerminalArtifact(provider, grant, binaryMaxBytes)
|
|
if (
|
|
!preview.isBinary &&
|
|
Buffer.byteLength(preview.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES
|
|
) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
if (
|
|
preview.isBinary &&
|
|
maxContentBytes !== undefined &&
|
|
Buffer.byteLength(preview.content, 'utf8') > maxContentBytes
|
|
) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
return preview
|
|
}
|
|
|
|
private async readRemoteTerminalArtifactFile(
|
|
provider: IFilesystemProvider,
|
|
grant: TerminalFileGrant,
|
|
maxBytes: number
|
|
): Promise<string> {
|
|
const result = await this.readRemoteTerminalArtifact(provider, grant, maxBytes)
|
|
if (result.isBinary) {
|
|
throw new Error('binary_file')
|
|
}
|
|
return result.content
|
|
}
|
|
|
|
private async readRemoteTerminalArtifact(
|
|
provider: IFilesystemProvider,
|
|
grant: TerminalFileGrant,
|
|
maxBytes: number
|
|
): Promise<RuntimeFilePreviewResult> {
|
|
if (!provider.readTerminalArtifact) {
|
|
throw new Error('terminal_file_grant_unavailable')
|
|
}
|
|
return provider.readTerminalArtifact(
|
|
grant.absolutePath,
|
|
this.terminalArtifactAccessOptions(grant, maxBytes)
|
|
)
|
|
}
|
|
|
|
private terminalArtifactAccessOptions(
|
|
grant: TerminalFileGrant,
|
|
maxBytes: number
|
|
): { expectedRealPath: string; expectedStatIdentity: string | null; maxBytes: number } {
|
|
return {
|
|
expectedRealPath: grant.absolutePath,
|
|
expectedStatIdentity: grant.statIdentity,
|
|
maxBytes
|
|
}
|
|
}
|
|
|
|
private async assertRemoteTerminalFileGrantFreshForRead(
|
|
grant: TerminalFileGrant
|
|
): Promise<IFilesystemProvider> {
|
|
const { provider } = await this.assertRemoteTerminalFileGrantFresh(grant)
|
|
return provider
|
|
}
|
|
|
|
private async assertRemoteTerminalFileGrantFresh(
|
|
grant: TerminalFileGrant
|
|
): Promise<{ provider: IFilesystemProvider; fileStat: FileStat }> {
|
|
const provider = await this.assertRemoteTerminalFileGrantPathStillCanonical(grant)
|
|
const fileStat = await provider.stat(grant.absolutePath)
|
|
assertTerminalFileGrantFresh(grant, fileStat)
|
|
return { provider, fileStat }
|
|
}
|
|
|
|
private async assertRemoteTerminalFileGrantPathStillCanonical(
|
|
grant: TerminalFileGrant
|
|
): Promise<IFilesystemProvider> {
|
|
if (!grant.connectionId) {
|
|
throw new Error('terminal_file_grant_mismatch')
|
|
}
|
|
const provider = getSshFilesystemProvider(grant.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
const canonicalPath =
|
|
grant.provenance === 'native-chat'
|
|
? await provider.realpath(grant.absolutePath)
|
|
: await this.resolveAllowedRemoteTerminalArtifactPath(
|
|
grant.absolutePath,
|
|
grant.connectionId
|
|
)
|
|
// Why: relay I/O follows symlinks, so re-canonicalize after the remote process can mutate the path.
|
|
if (canonicalPath !== grant.absolutePath) {
|
|
throw new Error('terminal_file_grant_stale')
|
|
}
|
|
return provider
|
|
}
|
|
|
|
async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise<DirEntry[]> {
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
// Why: re-sort locally — the remote relay may be an older build with
|
|
// lexicographic ordering.
|
|
return sortDirEntries(await provider.readDir(target.path))
|
|
}
|
|
|
|
const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
|
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
const mapped = await Promise.all(
|
|
entries.map(async (entry) => {
|
|
const entryPath = join(dirPath, entry.name)
|
|
return {
|
|
name: entry.name,
|
|
isDirectory: await isRuntimeDirectoryEntry(entry, entryPath),
|
|
isSymlink: entry.isSymbolicLink()
|
|
}
|
|
})
|
|
)
|
|
return sortDirEntries(mapped)
|
|
}
|
|
|
|
async watchFileExplorer(
|
|
worktreeSelector: string,
|
|
callback: (events: FsChangeEvent[]) => void,
|
|
onTerminalError: (error: Error) => void = () => undefined,
|
|
signal?: AbortSignal
|
|
): Promise<() => Promise<void>> {
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, '')
|
|
const open = async (): Promise<{
|
|
unsubscribe: () => Promise<void>
|
|
rootPaths: string[]
|
|
}> => {
|
|
const finishInstall = beginWatcherInstall(target.path, target.connectionId)
|
|
try {
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
// Why: the RPC layer already threads AbortSignal for local watches; SSH must cancel the remote fs.watch, not wait it out.
|
|
const close = await provider.watch(target.path, callback, { signal, onTerminalError })
|
|
const rearm = armSshFileExplorerWatchRearm({
|
|
runtimeId: this.host.getRuntimeId(),
|
|
connectionId: target.connectionId,
|
|
rootPath: target.path,
|
|
callback,
|
|
onTerminalError,
|
|
signal,
|
|
initialUnwatch: close
|
|
})
|
|
return { unsubscribe: rearm.unsubscribe, rootPaths: [target.path] }
|
|
}
|
|
|
|
const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
|
const rootStats = await stat(rootPath)
|
|
if (!rootStats.isDirectory()) {
|
|
throw new Error('not_a_directory')
|
|
}
|
|
if (process.platform === 'win32') {
|
|
const close = watchWindowsRuntimeFileExplorer(rootPath, callback, onTerminalError)
|
|
return { unsubscribe: close, rootPaths: [target.path, rootPath] }
|
|
}
|
|
// Why: the forked watcher keeps the blocking crawl and native faults out of the main/`serve` process (issues #5308, #8212).
|
|
const dispose = await watchFileExplorerInWatcherProcess(
|
|
rootPath,
|
|
callback,
|
|
onTerminalError,
|
|
signal
|
|
)
|
|
return { unsubscribe: dispose, rootPaths: [target.path, rootPath] }
|
|
} finally {
|
|
finishInstall()
|
|
}
|
|
}
|
|
const initial = await open()
|
|
return registerRuntimeFileWatcherRelease(
|
|
this.host.getRuntimeId(),
|
|
target.connectionId,
|
|
initial.rootPaths,
|
|
initial.unsubscribe,
|
|
async () => (await open()).unsubscribe,
|
|
onTerminalError
|
|
)
|
|
}
|
|
|
|
async closeFileExplorerWatchersForPath(rootPath: string, connectionId?: string): Promise<void> {
|
|
const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath)
|
|
const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key)
|
|
if (leases) {
|
|
await Promise.all(Array.from(leases, (lease) => lease.suspend()))
|
|
}
|
|
if (!connectionId) {
|
|
// Why: setup can fail before registerRuntimeFileWatcherRelease publishes its callback while the child owner still lives.
|
|
const resolvedRootPath = await resolveAuthorizedPath(rootPath, this.host.requireStore())
|
|
await closeFileExplorerWatcherInWatcherProcess(resolvedRootPath)
|
|
}
|
|
}
|
|
|
|
async restoreFileExplorerWatchersAfterFailedRemoval(
|
|
rootPath: string,
|
|
connectionId?: string
|
|
): Promise<void> {
|
|
const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath)
|
|
const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key)
|
|
if (leases) {
|
|
await Promise.all(Array.from(leases, (lease) => lease.resume()))
|
|
}
|
|
}
|
|
|
|
forgetFileExplorerWatchersAfterRemoval(rootPath: string, connectionId?: string): void {
|
|
const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath)
|
|
// Why: forget() never runs the lease's unsubscribe, so the re-arm would outlive a deleted
|
|
// worktree and re-watch it on the next reconnect.
|
|
stopSshFileExplorerWatchRearms(key)
|
|
const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key)
|
|
if (leases) {
|
|
for (const lease of Array.from(leases)) {
|
|
lease.forget()
|
|
}
|
|
}
|
|
}
|
|
|
|
async readFileExplorerPreview(
|
|
worktreeSelector: string,
|
|
relativePath: string,
|
|
maxContentBytes?: number
|
|
): Promise<RuntimeFilePreviewResult> {
|
|
const binaryMaxBytes =
|
|
maxContentBytes === undefined
|
|
? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES
|
|
: previewableBinaryByteLimit(maxContentBytes)
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
const fileStats = await provider.stat(target.path)
|
|
if (fileStats.size > binaryMaxBytes) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
const result = await readPreviewFileWithinCap(provider, target.path, {
|
|
maxBinaryBytes: binaryMaxBytes,
|
|
maxTextBytes: MOBILE_FILE_READ_MAX_BYTES
|
|
})
|
|
// Why: the stat gate sizes base64 binaries; text crosses the wire JSON-escaped (up to 6x), so
|
|
// hold it to the same decoded limit the local branch enforces before reading.
|
|
if (
|
|
!result.isBinary &&
|
|
Buffer.byteLength(result.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES
|
|
) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
if (
|
|
result.isBinary &&
|
|
maxContentBytes !== undefined &&
|
|
Buffer.byteLength(result.content, 'utf8') > maxContentBytes
|
|
) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
return assertPreviewWithinTransportBudget(result, maxContentBytes)
|
|
}
|
|
|
|
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
|
const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()]
|
|
const maxBytes = mimeType ? binaryMaxBytes : MOBILE_FILE_READ_MAX_BYTES
|
|
let buffer: Buffer
|
|
try {
|
|
buffer = (await readNodeFileWithinLimit(filePath, maxBytes)).buffer
|
|
} catch (error) {
|
|
if (error instanceof NodeFileReadTooLargeError) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
throw error
|
|
}
|
|
if (mimeType) {
|
|
return assertPreviewWithinTransportBudget(
|
|
{
|
|
content: buffer.toString('base64'),
|
|
isBinary: true,
|
|
isImage: true,
|
|
mimeType
|
|
},
|
|
maxContentBytes
|
|
)
|
|
}
|
|
|
|
if (isBinaryBuffer(buffer)) {
|
|
return assertPreviewWithinTransportBudget({ content: '', isBinary: true }, maxContentBytes)
|
|
}
|
|
return assertPreviewWithinTransportBudget(
|
|
{ content: buffer.toString('utf-8'), isBinary: false },
|
|
maxContentBytes
|
|
)
|
|
}
|
|
|
|
async readFileExplorerChunk(
|
|
worktreeSelector: string,
|
|
relativePath: string,
|
|
offset: number,
|
|
length: number
|
|
): Promise<RuntimeFileReadChunkResult> {
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
const fileStat = await provider.stat(target.path)
|
|
if (fileStat.type === 'directory') {
|
|
throw new Error('Cannot download a directory')
|
|
}
|
|
throw new Error('SSH runtime chunked download is unavailable; use the SSH download path')
|
|
}
|
|
|
|
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
|
const fileStats = await stat(filePath)
|
|
if (fileStats.isDirectory()) {
|
|
throw new Error('Cannot download a directory')
|
|
}
|
|
const handle = await open(filePath, 'r')
|
|
try {
|
|
const buffer = Buffer.alloc(Math.min(length, Math.max(0, fileStats.size - offset)))
|
|
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset)
|
|
const chunk = buffer.subarray(0, bytesRead)
|
|
return {
|
|
contentBase64: chunk.toString('base64'),
|
|
bytesRead,
|
|
eof: offset + bytesRead >= fileStats.size
|
|
}
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
}
|
|
|
|
async writeFileExplorerFile(
|
|
worktreeSelector: string,
|
|
relativePath: string,
|
|
content: string,
|
|
expectedSshConnectionGeneration?: number,
|
|
expectedSshTargetId?: string,
|
|
expectedExecutionHostId?: string
|
|
): Promise<{ ok: true }> {
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
|
assertRuntimeFileMutationExpectation(
|
|
target.connectionId,
|
|
expectedExecutionHostId,
|
|
expectedSshTargetId,
|
|
expectedSshConnectionGeneration
|
|
)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
await provider.writeFile(target.path, content)
|
|
return { ok: true }
|
|
}
|
|
|
|
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
|
try {
|
|
const fileStats = await lstat(filePath)
|
|
if (fileStats.isDirectory()) {
|
|
throw new Error('Cannot write to a directory')
|
|
}
|
|
} catch (error) {
|
|
if (!isENOENT(error)) {
|
|
throw error
|
|
}
|
|
}
|
|
await writeFile(filePath, content, 'utf-8')
|
|
return { ok: true }
|
|
}
|
|
|
|
async writeFileExplorerFileBase64(
|
|
worktreeSelector: string,
|
|
relativePath: string,
|
|
contentBase64: string,
|
|
expectedSshConnectionGeneration?: number,
|
|
expectedSshTargetId?: string,
|
|
expectedExecutionHostId?: string
|
|
): Promise<{ ok: true }> {
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
|
assertRuntimeFileMutationExpectation(
|
|
target.connectionId,
|
|
expectedExecutionHostId,
|
|
expectedSshTargetId,
|
|
expectedSshConnectionGeneration
|
|
)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
const content = Buffer.from(contentBase64, 'base64')
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
await provider.writeFileBase64(target.path, contentBase64)
|
|
return { ok: true }
|
|
}
|
|
|
|
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
|
await mkdir(dirname(filePath), { recursive: true })
|
|
await writeFile(filePath, content, { flag: 'wx' })
|
|
return { ok: true }
|
|
}
|
|
|
|
async writeFileExplorerFileBase64Chunk(
|
|
worktreeSelector: string,
|
|
relativePath: string,
|
|
contentBase64: string,
|
|
append: boolean,
|
|
expectedSshConnectionGeneration?: number,
|
|
expectedSshTargetId?: string,
|
|
expectedExecutionHostId?: string
|
|
): Promise<{ ok: true }> {
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
|
assertRuntimeFileMutationExpectation(
|
|
target.connectionId,
|
|
expectedExecutionHostId,
|
|
expectedSshTargetId,
|
|
expectedSshConnectionGeneration
|
|
)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
const content = Buffer.from(contentBase64, 'base64')
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
await provider.writeFileBase64Chunk(target.path, contentBase64, append)
|
|
return { ok: true }
|
|
}
|
|
|
|
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
|
await mkdir(dirname(filePath), { recursive: true })
|
|
await writeFile(filePath, content, { flag: append ? 'a' : 'wx' })
|
|
return { ok: true }
|
|
}
|
|
|
|
async createFileExplorerFile(
|
|
worktreeSelector: string,
|
|
relativePath: string,
|
|
expectedSshConnectionGeneration?: number,
|
|
expectedSshTargetId?: string,
|
|
expectedExecutionHostId?: string
|
|
): Promise<{ ok: true }> {
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
|
assertRuntimeFileMutationExpectation(
|
|
target.connectionId,
|
|
expectedExecutionHostId,
|
|
expectedSshTargetId,
|
|
expectedSshConnectionGeneration
|
|
)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
await provider.createFile(target.path)
|
|
return { ok: true }
|
|
}
|
|
|
|
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
|
await mkdir(dirname(filePath), { recursive: true })
|
|
try {
|
|
await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' })
|
|
} catch (error) {
|
|
rethrowRuntimeFileCreateError(error, filePath)
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
async createFileExplorerDir(
|
|
worktreeSelector: string,
|
|
relativePath: string,
|
|
expectedSshConnectionGeneration?: number,
|
|
expectedSshTargetId?: string,
|
|
expectedExecutionHostId?: string
|
|
): Promise<{ ok: true }> {
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
|
assertRuntimeFileMutationExpectation(
|
|
target.connectionId,
|
|
expectedExecutionHostId,
|
|
expectedSshTargetId,
|
|
expectedSshConnectionGeneration
|
|
)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
await provider.createDir(target.path)
|
|
return { ok: true }
|
|
}
|
|
|
|
const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
|
await assertRuntimePathDoesNotExist(dirPath)
|
|
await mkdir(dirPath, { recursive: false })
|
|
return { ok: true }
|
|
}
|
|
|
|
async createFileExplorerDirNoClobber(
|
|
worktreeSelector: string,
|
|
relativePath: string,
|
|
expectedSshConnectionGeneration?: number,
|
|
expectedSshTargetId?: string,
|
|
expectedExecutionHostId?: string
|
|
): Promise<{ ok: true }> {
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
|
assertRuntimeFileMutationExpectation(
|
|
target.connectionId,
|
|
expectedExecutionHostId,
|
|
expectedSshTargetId,
|
|
expectedSshConnectionGeneration
|
|
)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
await provider.createDirNoClobber(target.path)
|
|
return { ok: true }
|
|
}
|
|
|
|
const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
|
await mkdir(dirPath, { recursive: false })
|
|
return { ok: true }
|
|
}
|
|
|
|
async commitFileExplorerUpload(
|
|
worktreeSelector: string,
|
|
tempRelativePath: string,
|
|
finalRelativePath: string,
|
|
expectedSshConnectionGeneration?: number,
|
|
expectedSshTargetId?: string,
|
|
expectedExecutionHostId?: string
|
|
): Promise<{ ok: true }> {
|
|
const [tempTarget, finalTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [
|
|
tempRelativePath,
|
|
finalRelativePath
|
|
])
|
|
assertRuntimeFileMutationExpectation(
|
|
tempTarget.connectionId,
|
|
expectedExecutionHostId,
|
|
expectedSshTargetId,
|
|
expectedSshConnectionGeneration
|
|
)
|
|
const provider = tempTarget.connectionId
|
|
? getSshFilesystemProvider(tempTarget.connectionId)
|
|
: null
|
|
if (tempTarget.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
await provider.copy(tempTarget.path, finalTarget.path)
|
|
await provider.deletePath(tempTarget.path, false).catch(() => {})
|
|
return { ok: true }
|
|
}
|
|
|
|
const store = this.host.requireStore()
|
|
const tempPath = await resolveAuthorizedPath(tempTarget.path, store)
|
|
const finalPath = await resolveAuthorizedPath(finalTarget.path, store)
|
|
await mkdir(dirname(finalPath), { recursive: true })
|
|
await copyFile(tempPath, finalPath, constants.COPYFILE_EXCL)
|
|
await rm(tempPath, { force: true })
|
|
return { ok: true }
|
|
}
|
|
|
|
async renameFileExplorerPath(
|
|
worktreeSelector: string,
|
|
oldRelativePath: string,
|
|
newRelativePath: string,
|
|
expectedSshConnectionGeneration?: number,
|
|
expectedSshTargetId?: string,
|
|
expectedExecutionHostId?: string
|
|
): Promise<{ ok: true }> {
|
|
const [oldTarget, newTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [
|
|
oldRelativePath,
|
|
newRelativePath
|
|
])
|
|
assertRuntimeFileMutationExpectation(
|
|
oldTarget.connectionId,
|
|
expectedExecutionHostId,
|
|
expectedSshTargetId,
|
|
expectedSshConnectionGeneration
|
|
)
|
|
const provider = oldTarget.connectionId
|
|
? getSshFilesystemProvider(oldTarget.connectionId)
|
|
: null
|
|
if (oldTarget.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
await provider.renameNoClobber(oldTarget.path, newTarget.path)
|
|
return { ok: true }
|
|
}
|
|
|
|
const store = this.host.requireStore()
|
|
const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true })
|
|
const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true })
|
|
await renameLocalPathSerializedByDestination(oldPath, newPath)
|
|
return { ok: true }
|
|
}
|
|
|
|
async copyFileExplorerPath(
|
|
worktreeSelector: string,
|
|
sourceRelativePath: string,
|
|
destinationRelativePath: string,
|
|
expectedSshConnectionGeneration?: number,
|
|
expectedSshTargetId?: string,
|
|
expectedExecutionHostId?: string
|
|
): Promise<{ ok: true }> {
|
|
const [sourceTarget, destinationTarget] = await this.resolveFileExplorerPaths(
|
|
worktreeSelector,
|
|
[sourceRelativePath, destinationRelativePath]
|
|
)
|
|
assertRuntimeFileMutationExpectation(
|
|
sourceTarget.connectionId,
|
|
expectedExecutionHostId,
|
|
expectedSshTargetId,
|
|
expectedSshConnectionGeneration
|
|
)
|
|
const provider = sourceTarget.connectionId
|
|
? getSshFilesystemProvider(sourceTarget.connectionId)
|
|
: null
|
|
if (sourceTarget.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
await provider.copy(sourceTarget.path, destinationTarget.path)
|
|
return { ok: true }
|
|
}
|
|
|
|
const store = this.host.requireStore()
|
|
const sourcePath = await resolveAuthorizedPath(sourceTarget.path, store, {
|
|
preserveSymlink: true
|
|
})
|
|
const destinationPath = await resolveAuthorizedPath(destinationTarget.path, store, {
|
|
preserveSymlink: true
|
|
})
|
|
await mkdir(dirname(destinationPath), { recursive: true })
|
|
// Why: COPYFILE_EXCL preserves the no-clobber invariant of the local shell copy IPC (caller already deconflicts names).
|
|
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL)
|
|
return { ok: true }
|
|
}
|
|
|
|
async deleteFileExplorerPath(
|
|
worktreeSelector: string,
|
|
relativePath: string,
|
|
recursive?: boolean,
|
|
expectedSshConnectionGeneration?: number,
|
|
expectedSshTargetId?: string,
|
|
expectedExecutionHostId?: string
|
|
): Promise<{ ok: true }> {
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
|
assertRuntimeFileMutationExpectation(
|
|
target.connectionId,
|
|
expectedExecutionHostId,
|
|
expectedSshTargetId,
|
|
expectedSshConnectionGeneration
|
|
)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
await provider.deletePath(target.path, recursive)
|
|
return { ok: true }
|
|
}
|
|
|
|
const targetPath = await resolveAuthorizedPath(target.path, this.host.requireStore(), {
|
|
preserveSymlink: true
|
|
})
|
|
// Why: a non-local runtime has no client Trash; this delete is permanent, so the renderer confirms before calling.
|
|
await rm(targetPath, { recursive: recursive === true, force: true })
|
|
return { ok: true }
|
|
}
|
|
|
|
async searchRuntimeFiles(
|
|
worktreeSelector: string,
|
|
options: Omit<SearchOptions, 'rootPath'>
|
|
): Promise<SearchResult> {
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
const rootPath = target.worktree.path
|
|
const searchOptions = { ...options, rootPath }
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.search(searchOptions)
|
|
}
|
|
return this.searchLocalRuntimeFiles(rootPath, searchOptions)
|
|
}
|
|
|
|
async listRuntimeFiles(
|
|
worktreeSelector: string,
|
|
options: { excludePaths?: string[] } = {}
|
|
): Promise<string[]> {
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
return []
|
|
}
|
|
return provider.listFiles(target.worktree.path, { excludePaths: options.excludePaths })
|
|
}
|
|
return listQuickOpenFiles(target.worktree.path, this.host.requireStore(), options.excludePaths)
|
|
}
|
|
|
|
async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise<MarkdownDocument[]> {
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
const relativePaths = await provider.listFiles(target.worktree.path)
|
|
return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths)
|
|
}
|
|
return listMarkdownDocuments(target.worktree.path)
|
|
}
|
|
|
|
async statRuntimeFile(
|
|
worktreeSelector: string,
|
|
relativePath: string
|
|
): Promise<{ size: number; isDirectory: boolean; mtime: number }> {
|
|
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
|
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
|
if (target.connectionId) {
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
const fileStat = await provider.stat(target.path)
|
|
return {
|
|
size: fileStat.size,
|
|
isDirectory: fileStat.type === 'directory',
|
|
mtime: fileStat.mtime
|
|
}
|
|
}
|
|
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
|
const stats = await stat(filePath)
|
|
return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs }
|
|
}
|
|
|
|
private async searchLocalRuntimeFiles(
|
|
rootPath: string,
|
|
options: SearchOptions
|
|
): Promise<SearchResult> {
|
|
const store = this.host.requireStore()
|
|
const authorizedRootPath = await resolveAuthorizedPath(rootPath, store)
|
|
const localGitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
rootPath,
|
|
authorizedRootPath
|
|
)
|
|
const maxResults = Math.max(
|
|
1,
|
|
Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS)
|
|
)
|
|
const wslInfo = parseWslPath(authorizedRootPath)
|
|
if (
|
|
(wslInfo || localGitOptions.wslDistro) &&
|
|
!(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro))
|
|
) {
|
|
return searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions)
|
|
}
|
|
|
|
return new Promise<SearchResult>((resolvePromise) => {
|
|
const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}`
|
|
const rgArgs = buildRgArgs(options.query, authorizedRootPath, options)
|
|
const previousChild = this.activeRuntimeTextSearches.get(searchKey)
|
|
if (previousChild) {
|
|
killSpawnedRipgrepProcess(previousChild)
|
|
}
|
|
|
|
const acc = createAccumulator()
|
|
let stdoutBuffer = ''
|
|
let resolved = false
|
|
let processErrorObserved = false
|
|
let unavailableExitObserved = false
|
|
let child: ChildProcess | null = null
|
|
const transformAbsPath = wslInfo
|
|
? (p: string): string => toWindowsWslPath(p, wslInfo.distro)
|
|
: undefined
|
|
|
|
const finish = (result: SearchResult | PromiseLike<SearchResult>): void => {
|
|
if (resolved) {
|
|
return
|
|
}
|
|
resolved = true
|
|
if (this.activeRuntimeTextSearches.get(searchKey) === child) {
|
|
this.activeRuntimeTextSearches.delete(searchKey)
|
|
}
|
|
cleanupListeners()
|
|
resolvePromise(result)
|
|
}
|
|
const resolveOnce = (): void => finish(finalize(acc))
|
|
const resolveWithoutRipgrep = (): void =>
|
|
finish(searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions))
|
|
|
|
let killTimeout: ReturnType<typeof setTimeout> | null = null
|
|
const cleanupListeners = (): void => {
|
|
if (killTimeout) {
|
|
clearTimeout(killTimeout)
|
|
killTimeout = null
|
|
}
|
|
child?.stdout?.off('data', onStdoutData)
|
|
child?.stderr?.off('data', onStderrData)
|
|
child?.off('error', onError)
|
|
child?.off('close', onClose)
|
|
if (child) {
|
|
absorbPendingRipgrepSpawnError(child, {
|
|
errorObserved: processErrorObserved,
|
|
unavailableExitObserved
|
|
})
|
|
}
|
|
}
|
|
|
|
const processLine = (line: string): void => {
|
|
const verdict = ingestRgJsonLine(
|
|
line,
|
|
authorizedRootPath,
|
|
acc,
|
|
maxResults,
|
|
transformAbsPath
|
|
)
|
|
if (verdict === 'stop' && child) {
|
|
killSpawnedRipgrepProcess(child)
|
|
}
|
|
}
|
|
|
|
const nextChild = wslAwareSpawn('rg', rgArgs, {
|
|
cwd: authorizedRootPath,
|
|
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
child = nextChild
|
|
this.activeRuntimeTextSearches.set(searchKey, nextChild)
|
|
|
|
nextChild.stdout!.setEncoding('utf-8')
|
|
const onStdoutData = (chunk: string): void => {
|
|
stdoutBuffer += chunk
|
|
const lines = stdoutBuffer.split('\n')
|
|
stdoutBuffer = lines.pop() ?? ''
|
|
for (const line of lines) {
|
|
processLine(line)
|
|
}
|
|
}
|
|
const onStderrData = (): void => {
|
|
// Drain stderr so rg cannot block on a full pipe.
|
|
}
|
|
const onError = (): void => {
|
|
processErrorObserved = true
|
|
if (child && isRipgrepUnavailableExit(child, null, null)) {
|
|
resolveWithoutRipgrep()
|
|
return
|
|
}
|
|
resolveOnce()
|
|
}
|
|
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
if (
|
|
child &&
|
|
isRipgrepUnavailableExit(child, code, signal, {
|
|
classifyNativeLauncherExit: !(wslInfo || localGitOptions.wslDistro)
|
|
})
|
|
) {
|
|
unavailableExitObserved = true
|
|
resolveWithoutRipgrep()
|
|
return
|
|
}
|
|
if (stdoutBuffer) {
|
|
processLine(stdoutBuffer)
|
|
}
|
|
resolveOnce()
|
|
}
|
|
|
|
nextChild.stdout!.on('data', onStdoutData)
|
|
nextChild.stderr!.on('data', onStderrData)
|
|
nextChild.once('error', onError)
|
|
nextChild.once('close', onClose)
|
|
|
|
killTimeout = setTimeout(() => {
|
|
acc.truncated = true
|
|
if (child) {
|
|
killSpawnedRipgrepProcess(child)
|
|
}
|
|
resolveOnce()
|
|
}, SEARCH_TIMEOUT_MS)
|
|
})
|
|
}
|
|
|
|
private async resolveFileExplorerPath(
|
|
worktreeSelector: string,
|
|
relativePath: string
|
|
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> {
|
|
const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath])
|
|
return target
|
|
}
|
|
|
|
private async resolveFileExplorerPaths(
|
|
worktreeSelector: string,
|
|
relativePaths: readonly string[]
|
|
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }[]> {
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
return relativePaths.map((relativePath) => ({
|
|
worktree: target.worktree,
|
|
path: joinWorktreeRelativePath(
|
|
target.worktree.path,
|
|
normalizeRuntimeRelativePath(relativePath)
|
|
),
|
|
connectionId: target.connectionId
|
|
}))
|
|
}
|
|
|
|
private async listRemoteMobileFiles(
|
|
rootPath: string,
|
|
connectionId: string,
|
|
maxResults?: number
|
|
): Promise<string[]> {
|
|
const provider = getSshFilesystemProvider(connectionId)
|
|
if (!provider) {
|
|
return []
|
|
}
|
|
return provider.listFiles(rootPath, { maxResults })
|
|
}
|
|
|
|
private async readRemoteMobileFile(filePath: string, connectionId: string): Promise<string> {
|
|
const provider = getSshFilesystemProvider(connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
const fileStat = await provider.stat(filePath)
|
|
// Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it.
|
|
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
const result = await provider.readFile(filePath)
|
|
if (result.isBinary) {
|
|
throw new Error('binary_file')
|
|
}
|
|
return result.content
|
|
}
|
|
}
|
|
|
|
function watchWindowsRuntimeFileExplorer(
|
|
rootPath: string,
|
|
callback: (events: FsChangeEvent[]) => void,
|
|
onTerminalError: (error: Error) => void
|
|
): () => Promise<void> {
|
|
let disposed = false
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
let closeStarted = false
|
|
const physicalClose = new PhysicalExitTracker()
|
|
|
|
const emitOverflow = (): void => {
|
|
timer = null
|
|
if (disposed) {
|
|
return
|
|
}
|
|
callback([{ kind: 'overflow', absolutePath: rootPath }])
|
|
}
|
|
|
|
const scheduleOverflow = (): void => {
|
|
if (disposed) {
|
|
return
|
|
}
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
}
|
|
timer = setTimeout(emitOverflow, WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS)
|
|
}
|
|
|
|
// Why: Parcel's Watchman probe can crash the headless server on Windows; use a conservative overflow refresh instead.
|
|
const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow)
|
|
const onClose = (): void => {
|
|
watcher.removeListener('error', onError)
|
|
physicalClose.markExited()
|
|
}
|
|
const onError = (err: Error): void => {
|
|
console.error('[runtime-files.watch] Windows watcher error', { rootPath, err })
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
watcher.removeListener('close', onClose)
|
|
watcher.removeListener('error', onError)
|
|
// Why: Node nulls FSWatcher's native handle on error without a close event; treat the error as physical-exit proof.
|
|
physicalClose.markExited()
|
|
if (!disposed) {
|
|
try {
|
|
callback([{ kind: 'overflow', absolutePath: rootPath }])
|
|
} finally {
|
|
onTerminalError(err)
|
|
}
|
|
}
|
|
}
|
|
watcher.once('close', onClose)
|
|
watcher.on('error', onError)
|
|
|
|
return async () => {
|
|
disposed = true
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
if (!closeStarted) {
|
|
try {
|
|
watcher.close()
|
|
} catch (err) {
|
|
console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err })
|
|
throw err
|
|
}
|
|
closeStarted = true
|
|
}
|
|
try {
|
|
await physicalClose.waitForExit(
|
|
WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS,
|
|
() => new Error('Windows watcher did not close before deletion deadline')
|
|
)
|
|
} catch (error) {
|
|
// Why: late Windows close still owns native dir handles; expose its completion so cleanup retains then clears the root.
|
|
throw new WatcherProcessFailure(
|
|
error instanceof Error ? error.message : String(error),
|
|
'supervisor',
|
|
'process_unavailable',
|
|
physicalClose.exitedPromise
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function isSafeMobileRelativePath(relativePath: string): boolean {
|
|
if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) {
|
|
return false
|
|
}
|
|
const parts = relativePath.replace(/\\/g, '/').split('/')
|
|
return parts.every((part) => part !== '' && part !== '.' && part !== '..')
|
|
}
|
|
|
|
function isMobileMarkdownPath(relativePath: string): boolean {
|
|
return /\.(md|mdx|markdown)$/i.test(relativePath)
|
|
}
|
|
|
|
function isMobileBinaryPath(relativePath: string): boolean {
|
|
const basename = basenameFromRelativePath(relativePath)
|
|
const dotIndex = basename.lastIndexOf('.')
|
|
if (dotIndex <= 0) {
|
|
return false
|
|
}
|
|
return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase())
|
|
}
|
|
|
|
function basenameFromRelativePath(relativePath: string): string {
|
|
const normalized = relativePath.replace(/\\/g, '/')
|
|
return normalized.slice(normalized.lastIndexOf('/') + 1)
|
|
}
|
|
|
|
async function isRuntimeDirectoryEntry(
|
|
entry: { isDirectory(): boolean; isSymbolicLink(): boolean },
|
|
_entryPath: string
|
|
): Promise<boolean> {
|
|
// Why: listings are passive UI reads; don't stat symlink targets here (explicit open/expand resolves them).
|
|
if (entry.isSymbolicLink()) {
|
|
void _entryPath
|
|
return false
|
|
}
|
|
if (entry.isDirectory()) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function isBinaryBuffer(buffer: Buffer): boolean {
|
|
const len = Math.min(buffer.length, 8192)
|
|
for (let i = 0; i < len; i += 1) {
|
|
if (buffer[i] === 0) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function assertRuntimePathDoesNotExist(targetPath: string): Promise<void> {
|
|
try {
|
|
await lstat(targetPath)
|
|
throw new Error(
|
|
`A file or folder named '${basename(targetPath)}' already exists in this location`
|
|
)
|
|
} catch (error) {
|
|
if (!isENOENT(error)) {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
function rethrowRuntimeFileCreateError(error: unknown, targetPath: string): never {
|
|
const name = basename(targetPath)
|
|
if (error instanceof Error && 'code' in error) {
|
|
const code = (error as NodeJS.ErrnoException).code
|
|
if (code === 'EEXIST') {
|
|
throw new Error(`A file or folder named '${name}' already exists in this location`)
|
|
}
|
|
if (code === 'EACCES' || code === 'EPERM') {
|
|
throw new Error(`Permission denied: unable to create '${name}'`)
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
|
|
async function readLocalMobileFile(filePath: string, store: Store): Promise<string> {
|
|
const authorizedPath = await resolveAuthorizedPath(filePath, store)
|
|
const fileStat = await stat(authorizedPath)
|
|
// Why: cap the read so opening a large file can't block the WebSocket (previews are read-only convenience views).
|
|
const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1)
|
|
const handle = await open(authorizedPath, 'r')
|
|
try {
|
|
const buffer = Buffer.alloc(readLimit)
|
|
const { bytesRead } = await handle.read(buffer, 0, readLimit, 0)
|
|
return buffer.subarray(0, bytesRead).toString('utf8')
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
}
|
|
|
|
async function readLocalTerminalArtifactFileFromHandle(
|
|
handle: FileHandle,
|
|
grant: TerminalFileGrant
|
|
): Promise<string> {
|
|
const fileStat = await handle.stat()
|
|
if (fileStat.isDirectory()) {
|
|
throw new Error('Cannot read a directory')
|
|
}
|
|
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
assertTerminalFileGrantFresh(grant, fileStat)
|
|
const buffer = await readFileHandleBufferBounded(handle, MOBILE_FILE_READ_MAX_BYTES + 1)
|
|
if (isBinaryBuffer(buffer)) {
|
|
throw new Error('binary_file')
|
|
}
|
|
return buffer.toString('utf8')
|
|
}
|
|
|
|
async function readLocalTerminalArtifactPreviewFromHandle(
|
|
handle: FileHandle,
|
|
grant: TerminalFileGrant,
|
|
maxContentBytes: number | undefined
|
|
): Promise<RuntimeFilePreviewResult> {
|
|
const fileStats = await handle.stat()
|
|
if (fileStats.isDirectory()) {
|
|
throw new Error('Cannot preview a directory')
|
|
}
|
|
assertTerminalFileGrantFresh(grant, fileStats)
|
|
const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(grant.absolutePath).toLowerCase()]
|
|
if (mimeType) {
|
|
const binaryMaxBytes =
|
|
maxContentBytes === undefined
|
|
? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES
|
|
: previewableBinaryByteLimit(maxContentBytes)
|
|
if (fileStats.size > binaryMaxBytes) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
const buffer = await readFileHandleBufferBounded(handle, binaryMaxBytes + 1)
|
|
if (buffer.byteLength > binaryMaxBytes) {
|
|
throw new Error('file_too_large')
|
|
}
|
|
return {
|
|
content: buffer.toString('base64'),
|
|
isBinary: true,
|
|
isImage: true,
|
|
mimeType
|
|
}
|
|
}
|
|
|
|
const content = await readLocalTerminalArtifactFileFromHandle(handle, grant)
|
|
return { content, isBinary: false }
|
|
}
|
|
|
|
async function assertLocalTerminalArtifactPathStillCanonical(filePath: string): Promise<void> {
|
|
const currentPath = await canonicalPathForArtifactComparison(filePath)
|
|
if (currentPath !== filePath) {
|
|
throw new Error('terminal_file_grant_stale')
|
|
}
|
|
}
|
|
|
|
async function openLocalTerminalArtifactGrant(
|
|
grant: TerminalFileGrant,
|
|
flags: number
|
|
): Promise<FileHandle> {
|
|
await assertLocalTerminalArtifactPathStillCanonical(grant.absolutePath)
|
|
try {
|
|
return await open(grant.absolutePath, flags | OPEN_NOFOLLOW)
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
|
|
throw new Error('terminal_file_grant_stale')
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function resolveTerminalAbsolutePath(args: {
|
|
base: string
|
|
expanded: string
|
|
worktreePath: string
|
|
connectionId?: string
|
|
terminalFileUriHostname?: string | null
|
|
}): string {
|
|
const expanded = normalizeTerminalFileUriAuthorityPath(
|
|
args.expanded,
|
|
args.connectionId,
|
|
args.terminalFileUriHostname,
|
|
args.worktreePath
|
|
)
|
|
const absolutePath = isRuntimePathAbsolute(expanded)
|
|
? expanded
|
|
: resolveRuntimePath(args.base, expanded)
|
|
if (args.connectionId) {
|
|
return normalizeLeadingSlashDrivePath(absolutePath, args.worktreePath)
|
|
}
|
|
const wsl = parseWslPath(args.worktreePath)
|
|
if (wsl && absolutePath.startsWith('/') && !absolutePath.startsWith('//')) {
|
|
return toWindowsWslPath(absolutePath, wsl.distro)
|
|
}
|
|
return absolutePath
|
|
}
|
|
|
|
function normalizeTerminalFileUriAuthorityPath(
|
|
pathText: string,
|
|
connectionId?: string,
|
|
terminalFileUriHostname?: string | null,
|
|
worktreePath?: string
|
|
): string {
|
|
if (!pathText.startsWith('//')) {
|
|
return pathText
|
|
}
|
|
const match = /^\/\/([^/\\]+)([/\\].*)$/.exec(pathText)
|
|
if (!match) {
|
|
return pathText
|
|
}
|
|
const host = match[1]!.toLowerCase()
|
|
if (terminalFileUriHostname && host === terminalFileUriHostname.toLowerCase() && connectionId) {
|
|
return normalizeLeadingSlashDrivePath(match[2]!, worktreePath)
|
|
}
|
|
if (isLoopbackFileUriHostname(host) && (connectionId || process.platform !== 'win32')) {
|
|
return normalizeLeadingSlashDrivePath(match[2]!, worktreePath)
|
|
}
|
|
// Why: without a verified host match, stripping the file-URI authority could open a same-path artifact on the wrong machine.
|
|
return pathText
|
|
}
|
|
|
|
function provenancePathCandidate(pathText: string, absolutePath: string): string {
|
|
return pathText.startsWith('//') ? pathText : absolutePath
|
|
}
|
|
|
|
function isLoopbackFileUriHostname(hostname: string): boolean {
|
|
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
|
|
}
|
|
|
|
function normalizeLeadingSlashDrivePath(pathText: string, worktreePath?: string): string {
|
|
return worktreePath &&
|
|
isWindowsAbsolutePathLike(worktreePath) &&
|
|
/^\/[A-Za-z]:[\\/]/.test(pathText)
|
|
? pathText.slice(1)
|
|
: pathText
|
|
}
|
|
|
|
async function resolveAllowedLocalTerminalArtifactPath(
|
|
absolutePath: string,
|
|
worktreePath: string
|
|
): Promise<string | null> {
|
|
const roots = await localTerminalArtifactRoots(worktreePath)
|
|
const canonicalPath = await canonicalPathForArtifactComparison(absolutePath)
|
|
return roots.some((root) => isPathInsideOrEqual(root, canonicalPath)) ? canonicalPath : null
|
|
}
|
|
|
|
async function localTerminalArtifactRoots(worktreePath: string): Promise<string[]> {
|
|
const roots = new Set<string>([tmpdir()])
|
|
if (process.platform !== 'win32') {
|
|
roots.add('/tmp')
|
|
roots.add('/private/tmp')
|
|
}
|
|
const wsl = parseWslPath(worktreePath)
|
|
if (wsl) {
|
|
roots.add(toWindowsWslPath('/tmp', wsl.distro))
|
|
}
|
|
const canonicalRoots = await Promise.all(
|
|
Array.from(roots).map((root) => canonicalPathForArtifactComparison(root))
|
|
)
|
|
return Array.from(new Set([...roots, ...canonicalRoots]))
|
|
}
|
|
|
|
async function canonicalPathForArtifactComparison(path: string): Promise<string> {
|
|
try {
|
|
return await realpath(path)
|
|
} catch {
|
|
return path
|
|
}
|
|
}
|
|
|
|
async function readFileHandleBufferBounded(handle: FileHandle, limit: number): Promise<Buffer> {
|
|
const buffer = Buffer.alloc(limit)
|
|
const { bytesRead } = await handle.read(buffer, 0, limit, 0)
|
|
return buffer.subarray(0, bytesRead)
|
|
}
|
|
|
|
function terminalFileStatIdentity(stats: RuntimeFileStatLike): string | null {
|
|
const dev = typeof stats.dev === 'number' ? stats.dev : null
|
|
const ino = typeof stats.ino === 'number' ? stats.ino : null
|
|
const nlink = typeof stats.nlink === 'number' ? stats.nlink : null
|
|
const size = typeof stats.size === 'number' ? stats.size : null
|
|
const mtimeMs =
|
|
typeof stats.mtimeMs === 'number'
|
|
? stats.mtimeMs
|
|
: typeof stats.mtime === 'number'
|
|
? stats.mtime
|
|
: null
|
|
if (dev !== null && ino !== null && size !== null && mtimeMs !== null) {
|
|
return `${dev}:${ino}:${nlink ?? 'unknown'}:${size}:${mtimeMs}`
|
|
}
|
|
if (size !== null && mtimeMs !== null) {
|
|
return `${size}:${mtimeMs}`
|
|
}
|
|
return null
|
|
}
|
|
|
|
function assertTerminalFileGrantFresh(grant: TerminalFileGrant, stats: RuntimeFileStatLike): void {
|
|
assertTerminalArtifactNotHardLinked(stats)
|
|
const nextIdentity = terminalFileStatIdentity(stats)
|
|
if (grant.statIdentity !== null && nextIdentity !== null && grant.statIdentity !== nextIdentity) {
|
|
throw new Error('terminal_file_grant_stale')
|
|
}
|
|
}
|
|
|
|
function assertTerminalArtifactNotHardLinked(stats: RuntimeFileStatLike): void {
|
|
if (isTerminalArtifactHardLinked(stats)) {
|
|
throw new Error('terminal_file_grant_stale')
|
|
}
|
|
}
|
|
|
|
function isTerminalArtifactHardLinked(stats: RuntimeFileStatLike): boolean {
|
|
return typeof stats.nlink === 'number' && stats.nlink > 1
|
|
}
|
|
|
|
function truncateMobileFilePreview(content: string): {
|
|
content: string
|
|
truncated: boolean
|
|
byteLength: number
|
|
} {
|
|
const buffer = Buffer.from(content, 'utf8')
|
|
if (buffer.byteLength <= MOBILE_FILE_READ_MAX_BYTES) {
|
|
return { content, truncated: false, byteLength: buffer.byteLength }
|
|
}
|
|
return {
|
|
content: buffer.subarray(0, MOBILE_FILE_READ_MAX_BYTES).toString('utf8'),
|
|
truncated: true,
|
|
byteLength: buffer.byteLength
|
|
}
|
|
}
|