1
/**2
* Host-filesystem implementation of `ctx.fs`. Realpath-derived target identity makes aliases3
* share stale guards, and writes through a symlink update its target without replacing the link.4
* @module @deepseek-ai/dsh-fs-local5
*/7
import { Context } from '@deepseek-ai/cordis'8
import { constants as bufferConstants } from 'node:buffer'9
import { once } from 'node:events'10
import { watch } from 'chokidar'11
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'12
import { pathToFileURL } from 'node:url'13
import z from '@deepseek-ai/schemastery'14
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'15
import type {16
FsDirEntry,17
FsEditOutcome,18
FsEditRequest,19
FsInfo,20
FsPathInfo,21
FsTarget,22
FsWriteIntent,23
FsWriteOutcome,24
} from '@deepseek-ai/dsh-fs'25
import {26
applyLiteralEdit,27
listDirectory,28
localDisplayPath,29
normalizeLineEndings,30
probe,31
probeNoFollow,32
readForEdit,33
readByteWindow,34
readTextForDiff,35
readWholeBytes,36
readWholeText,37
resolveLocalTarget,38
restoreLineEndings,39
streamWholeText,40
writeFileAtomic,41
} from './fsio.ts'42
import type { FsIoInternals } from './fsio.ts'44
/** Configuration for the local filesystem backend. */45
export interface Config {46
/** Base directory for relative paths. Defaults to `process.cwd()`. */47
cwd?: string48
/**49
* Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the50
* runtime's safe allocation/decode maximum. Defaults to 10 MiB.51
*/52
diffBasisMaxBytes?: number53
}55
type ResolvedConfig = Required<Config>56
const DEFAULT_DIFF_BASIS_MAX_BYTES = 10 * 1024 * 102457
const MAX_DIFF_BASIS_BYTES = Math.min(58
bufferConstants.MAX_LENGTH,59
bufferConstants.MAX_STRING_LENGTH,60
)62
/**63
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}64
* (a resolution default, NOT a containment boundary — see the filesystem65
* capability-seam Agent Note); enforce66
* containment with a stricter backend or a `tools/execute` permission plugin.67
*/68
export class LocalFileSystem extends FileSystem {69
override async watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>> {70
signal.throwIfAborted()71
const path = resolve(this.processPath(target))72
const directory = (await this.stat(target, signal))?.type === 'directory'73
signal.throwIfAborted()74
const root = directory ? path : dirname(path)75
const watcher = watch(root, {76
ignoreInitial: true, depth: 0,77
ignored: entry => !directory && resolve(entry) !== root && resolve(entry) !== path,78
})79
watcher.on('all', (_event, entry) => {80
if (directory || resolve(entry) === path) changed()81
})82
watcher.on('error', (error) => { changed(error instanceof Error ? error : new Error(String(error))) })83
try {84
await once(watcher, 'ready', { signal })85
return () => watcher.close()86
} catch (error) {87
await watcher.close()88
throw error89
}90
}92
static Config: z<Config> = z.object({93
cwd: z.string().default(process.cwd()),94
diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES),95
})97
/** Validated config (schemastery applied the defaults before construction). */98
readonly config: ResolvedConfig99
/** Test hook forwarded to fsio for atomic-publication boundaries. */100
internals: FsIoInternals = {}101
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write102
* window can't interleave, making concurrent writes/edits deterministically103
* ordered (one wins, the rest see the new version and reject as stale). */104
private locks = new Map<string, Promise<unknown>>()106
constructor(ctx: Context, config: Config) {107
super(ctx)108
const resolved = config as ResolvedConfig109
if (!Number.isSafeInteger(resolved.diffBasisMaxBytes)110
|| resolved.diffBasisMaxBytes <= 0111
|| resolved.diffBasisMaxBytes > MAX_DIFF_BASIS_BYTES) {112
throw new Error(`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${MAX_DIFF_BASIS_BYTES}`)113
}114
this.config = resolved115
}117
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */118
private async withLock<T>(targetKey: string, op: () => Promise<T>): Promise<T> {119
const prior = this.locks.get(targetKey) ?? Promise.resolve()120
const run = prior.then(op, op)121
// Keep the chain alive but swallow this op's result/throw for the *next* waiter.122
const tail = run.then(() => undefined, () => undefined)123
this.locks.set(targetKey, tail)124
try {125
return await run126
} finally {127
if (this.locks.get(targetKey) === tail) {128
this.locks.delete(targetKey)129
}130
}131
}133
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {134
if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')135
const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)136
if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')137
return { targetKey: local.targetKey, displayPath: local.displayPath }138
}140
override processPath(target: FsTarget): string {141
return String(target.targetKey)142
}144
override processPathFromHostPath(hostPath: string): string | undefined {145
return isAbsolute(hostPath) ? resolve(hostPath) : undefined146
}148
override fileUrl(target: FsTarget): string {149
return pathToFileURL(this.processPath(target)).href150
}152
override contains(parent: FsTarget, child: FsTarget): boolean {153
const path = relative(this.processPath(parent), this.processPath(child))154
return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path))155
}157
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {158
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')159
const info = await probe(target.targetKey)160
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')161
if (!info) return undefined162
return { version: info.version, type: info.type, size: info.size }163
}165
override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {166
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')167
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')168
const cwd = opts?.cwd ?? this.config.cwd169
const info = await probeNoFollow(localDisplayPath(cwd, path))170
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')171
if (!info) return undefined172
return { version: info.version, type: info.type, size: info.size }173
}175
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {176
return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)177
}179
override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {180
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))181
}183
override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {184
return readWholeBytes({ displayPath: target.displayPath, targetKey: target.targetKey }, signal, maxBytes, this.internals)185
}187
override async readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise<Uint8Array> {188
return readByteWindow({ displayPath: target.displayPath, targetKey: target.targetKey }, range, signal)189
}191
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {192
const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)193
return entries.map(entry => ({194
name: entry.name,195
type: entry.type,196
target: { targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },197
...(entry.version !== undefined ? { version: entry.version } : {}),198
...(entry.size !== undefined ? { size: entry.size } : {}),199
}))200
}202
override async writeText(203
target: FsTarget,204
content: string,205
expected?: FsWriteIntent,206
signal?: AbortSignal,207
): Promise<FsWriteOutcome> {208
return this.withLock(target.targetKey, async () => {209
const existing = await probe(target.targetKey)210
if (existing && existing.type !== 'file') {211
throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')212
}214
if (expected?.kind === 'replaceIfVersion') {215
// Stale guard: the file must still exist at the version the owner observed.216
if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')217
if (existing.version !== expected.version) {218
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')219
}220
} else if (expected?.kind === 'createIfAbsent' && existing) {221
// createIfAbsent onto an existing file: a blind overwrite — require a read first.222
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')223
}224
// No expectation means an unconditional but still atomic write.226
// Capture an optional contextual-diff basis before the write. The bounded227
// reader checks the opened file itself, so an external replacement after228
// `probe()` cannot turn this best-effort presentation read into an229
// unbounded allocation. Either side at/above the configured limit yields230
// `before: null`; consumers retain their whole-file fallback.231
const diffable = existing !== null232
&& Buffer.byteLength(content, 'utf8') < this.config.diffBasisMaxBytes233
const before = diffable234
? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal)235
: null236
await writeFileAtomic(237
target.targetKey,238
content,239
existing?.mode,240
signal,241
this.internals,242
expected?.kind === 'createIfAbsent' ? { displayPath: target.displayPath } : undefined,243
)244
const after = await probe(target.targetKey)245
return {246
operation: existing ? 'update' : 'create',247
version: this.versionAfterWrite(after, target),248
before,249
// LF-normalized to share the diff basis with `before` (also LF): a CRLF250
// overwrite must not read as every line changed. Line-ending restoration251
// is a storage detail the applied-hunk diff ignores.252
after: normalizeLineEndings(content),253
}254
})255
}257
override async editText(258
target: FsTarget,259
edit: FsEditRequest,260
expected?: { version: FsVersion },261
signal?: AbortSignal,262
): Promise<FsEditOutcome> {263
return this.withLock(target.targetKey, async () => {264
const existing = await probe(target.targetKey)265
// Stale guard before literal matching: an edit based on an old read reports266
// FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content.267
// Missing targets use the same stale code on guarded and unconditional edit paths.268
if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')269
if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')270
// expected === undefined: unconditional edit of the current content — no271
// version guard. Still inside the per-target lock, so the read→match→write272
// window is serialized and atomic.273
if (expected && existing.version !== expected.version) {274
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')275
}277
const original = await readForEdit(target.targetKey, target.displayPath, signal)278
const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath)279
const content = restoreLineEndings(edited.content, original.lineEndings)280
await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals)282
const after = await probe(target.targetKey)283
return {284
version: this.versionAfterWrite(after, target),285
// The LF-normalized before/after text (the applied-hunk diff basis);286
// line-ending restoration is a storage detail the diff ignores.287
before: original.content,288
after: edited.content,289
}290
})291
}293
/* v8 ignore next 5 -- the post-write probe finding the file absent requires a294
* concurrent unlink between rename and stat; fall back to a sentinel version. */295
private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion {296
if (after) return after.version297
return FsVersion(`missing:${target.targetKey}`)298
}299
}301
export default LocalFileSystem