Preserve instruction symlink guard through fs seam
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { Dirent, Stats } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { TextDecoder } from 'node:util'
|
||||
@@ -111,6 +111,14 @@ export interface PathInfo {
|
||||
size: number
|
||||
}
|
||||
|
||||
/** Result of probing a path without following the final symlink component. */
|
||||
export interface PathLinkInfo {
|
||||
version: FsVersion
|
||||
mode: number
|
||||
type: 'file' | 'directory' | 'symlink' | 'other'
|
||||
size: number
|
||||
}
|
||||
|
||||
/** One local directory child with a resolved target and cheap metadata. */
|
||||
export interface LocalDirEntry {
|
||||
name: string
|
||||
@@ -165,21 +173,44 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe a path for its version, mode, type, and size. Null if absent. */
|
||||
export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
function pathType(info: Stats): PathInfo['type'] {
|
||||
if (info.isFile()) return 'file'
|
||||
if (info.isDirectory()) return 'directory'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function pathLinkType(info: Stats): PathLinkInfo['type'] {
|
||||
if (info.isSymbolicLink()) return 'symlink'
|
||||
return pathType(info)
|
||||
}
|
||||
|
||||
async function probeStats(absolutePath: string, readStats: (path: string) => Promise<Stats>): Promise<Stats | null> {
|
||||
try {
|
||||
const info = await stat(absolutePath)
|
||||
const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
|
||||
return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size }
|
||||
return await readStats(absolutePath)
|
||||
} catch (error: unknown) {
|
||||
// ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean
|
||||
// the target is absent; any other stat failure is a real permission/IO fault.
|
||||
/* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */
|
||||
// the target is absent; any other metadata failure is a real permission/IO
|
||||
// fault.
|
||||
/* v8 ignore next -- a non-ENOENT/ENOTDIR metadata failure needs a permission/IO fault; surface it. */
|
||||
if (!isENOENT(error) && !isENOTDIR(error)) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe a path for its version, mode, type, and size. Null if absent. */
|
||||
export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
const info = await probeStats(absolutePath, stat)
|
||||
if (!info) return null
|
||||
return { version: versionOf(info), mode: info.mode & 0o777, type: pathType(info), size: info.size }
|
||||
}
|
||||
|
||||
/** Probe a path without following the final symlink component. Null if absent. */
|
||||
export async function probeNoFollow(absolutePath: string): Promise<PathLinkInfo | null> {
|
||||
const info = await probeStats(absolutePath, lstat)
|
||||
if (!info) return null
|
||||
return { version: versionOf(info), mode: info.mode & 0o777, type: pathLinkType(info), size: info.size }
|
||||
}
|
||||
|
||||
// --- Directory listing ---
|
||||
|
||||
function listingIoError(displayPath: string, error: unknown): FsError {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Local-filesystem implementation of the `ctx.fs` provider seam.
|
||||
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven
|
||||
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the eight
|
||||
* text-storage primitives with the host filesystem via
|
||||
* {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses
|
||||
* `realpath`, so the stable `targetKey` is the real file identity (two input
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { resolve } from 'node:path'
|
||||
import z from 'schemastery'
|
||||
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsPathInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
listDirectory,
|
||||
normalizeLineEndings,
|
||||
probe,
|
||||
probeNoFollow,
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeText,
|
||||
@@ -44,6 +47,7 @@ export {
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
probe,
|
||||
probeNoFollow,
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeText,
|
||||
@@ -52,7 +56,7 @@ export {
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from './fsio.ts'
|
||||
export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts'
|
||||
export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo, PathLinkInfo } from './fsio.ts'
|
||||
|
||||
/** Configuration for the local filesystem backend. */
|
||||
export interface Config {
|
||||
@@ -114,6 +118,14 @@ export class LocalFileSystem extends FileSystem {
|
||||
return { version: info.version, type: info.type, size: info.size }
|
||||
}
|
||||
|
||||
override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
|
||||
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
|
||||
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
|
||||
const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path))
|
||||
if (!info) return undefined
|
||||
return { version: info.version, type: info.type, size: info.size }
|
||||
}
|
||||
|
||||
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
|
||||
return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,29 @@ describe('stat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('lstat', () => {
|
||||
it('reports path metadata without following the final symlink component', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
|
||||
expect((await fs.lstat('real.txt'))?.type).toBe('file')
|
||||
expect((await fs.lstat('link.txt'))?.type).toBe('symlink')
|
||||
expect(await fs.lstat('missing.txt')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves relative paths against opts.cwd and honors a pre-aborted signal', async () => {
|
||||
const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
|
||||
try {
|
||||
await writeFile(join(other, 'x.txt'), 'in other')
|
||||
expect((await fs.lstat('x.txt', { cwd: other }))?.type).toBe('file')
|
||||
await expect(fs.lstat('x.txt', { cwd: other }, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(fs.lstat(' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
} finally {
|
||||
await rm(other, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('readText / streamText', () => {
|
||||
it('reads whole-file text', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
probe,
|
||||
probeNoFollow,
|
||||
readForEdit,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
@@ -146,6 +147,27 @@ describe('probe', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeNoFollow', () => {
|
||||
it('reports symlinks without following them', async () => {
|
||||
const real = join(dir, 'real.txt')
|
||||
const link = join(dir, 'link.txt')
|
||||
await writeFile(real, 'hi')
|
||||
await symlink(real, link)
|
||||
|
||||
expect((await probeNoFollow(real))?.type).toBe('file')
|
||||
const linkInfo = await probeNoFollow(link)
|
||||
expect(linkInfo?.type).toBe('symlink')
|
||||
expect(typeof linkInfo?.version).toBe('string')
|
||||
expect(linkInfo?.size).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns null for a missing path or a file-valued ancestor path segment', async () => {
|
||||
expect(await probeNoFollow(join(dir, 'missing'))).toBeNull()
|
||||
await writeFile(join(dir, 'afile'), 'i am a file')
|
||||
expect(await probeNoFollow(join(dir, 'afile', 'child.txt'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDirectory', () => {
|
||||
it('lists direct children in stable order without reading content', async () => {
|
||||
const root = join(dir, 'skills')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-fs
|
||||
|
||||
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
|
||||
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, no-follow path metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
|
||||
|
||||
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
|
||||
|
||||
@@ -15,12 +15,13 @@ A future sandboxed, virtual, or remote backend implements this interface and the
|
||||
|
||||
## Service API (`ctx.fs`)
|
||||
|
||||
A backend subclasses `FileSystem` and implements seven primitives.
|
||||
A backend subclasses `FileSystem` and implements eight primitives.
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
|
||||
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
|
||||
| `lstat(path, opts?, signal?)` | Return `FsPathInfo` metadata without following the final path component when it is a symlink. This is path-shaped so consumers can reject repository-owned symlinks before `resolve` follows them into a target. |
|
||||
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
|
||||
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
|
||||
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
|
||||
@@ -41,4 +42,4 @@ This package declares three events (see the generated [events catalog](../../../
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
|
||||
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
|
||||
|
||||
@@ -63,6 +63,7 @@ import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsPathInfo,
|
||||
FsTarget,
|
||||
FsVersion,
|
||||
FsWriteIntent,
|
||||
@@ -80,6 +81,7 @@ export type {
|
||||
FsDirEntry,
|
||||
FsErrorCode,
|
||||
FsInfo,
|
||||
FsPathInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
@@ -140,7 +142,7 @@ declare module 'cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract filesystem provider service. Subclass, implement the seven storage
|
||||
* Abstract filesystem provider service. Subclass, implement the eight storage
|
||||
* primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one
|
||||
* implementation per context; loading a second throws, cordis' standard
|
||||
* duplicate-service behavior).
|
||||
@@ -151,6 +153,10 @@ declare module 'cordis' {
|
||||
* guards and target lookup agree across paths (e.g. through symlinks).
|
||||
* - {@link stat} returns {@link FsInfo} metadata (never content) or `undefined`
|
||||
* when the target is absent.
|
||||
* - {@link lstat} returns path metadata without following the final path
|
||||
* component if it is a symlink. Consumers that treat repository-owned symlinks
|
||||
* as a trust-boundary hazard use this BEFORE {@link resolve}; ordinary target
|
||||
* operations still use `resolve` + `stat`.
|
||||
* - {@link readText}/{@link streamText} read the whole regular text file (the
|
||||
* stream for large files); both own regular-file checks, UTF-8 decoding,
|
||||
* binary/NUL rejection, and `FS_NOT_TEXT`.
|
||||
@@ -201,6 +207,22 @@ export abstract class FileSystem extends Service {
|
||||
*/
|
||||
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
|
||||
|
||||
/**
|
||||
* Return path metadata without following the final path component when it is a
|
||||
* symbolic link. This is intentionally path-shaped, not target-shaped:
|
||||
* {@link resolve} follows symlinks to produce the stable identity used by
|
||||
* normal reads/writes, while `lstat` lets a consumer reject the path itself
|
||||
* before that follow happens.
|
||||
*
|
||||
* `opts.cwd` follows {@link resolve}'s cwd rules. `undefined` means the path is
|
||||
* absent.
|
||||
* @param path - the path to inspect; relative paths resolve against `opts.cwd`.
|
||||
* @param opts - `cwd` overrides the backend's default base for relative paths.
|
||||
* @param signal - aborts the metadata round-trip.
|
||||
* @returns metadata only, never content; undefined for an absent path.
|
||||
*/
|
||||
abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined>
|
||||
|
||||
/**
|
||||
* Read the whole regular text file as a single decoded string.
|
||||
* @param target - the resolved target to read.
|
||||
|
||||
@@ -76,6 +76,21 @@ export interface FsInfo {
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about a path without following the final path component when it is a
|
||||
* symbolic link. Unlike {@link FsInfo}, this path-level probe can report
|
||||
* `symlink` so consumers with trust-boundary rules can reject repository-owned
|
||||
* links before resolving a target.
|
||||
*/
|
||||
export interface FsPathInfo {
|
||||
/** Opaque freshness token of the path entry right now. */
|
||||
version: FsVersion
|
||||
/** Whether the path entry is a regular file, directory, symlink, or other. */
|
||||
type: 'file' | 'directory' | 'symlink' | 'other'
|
||||
/** Byte size of the path entry, when the backend can report it. */
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One direct child returned by {@link FileSystem.listDir}. Listing returns
|
||||
* metadata and resolved targets only; it must not read file contents.
|
||||
|
||||
@@ -13,12 +13,13 @@ import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsPathInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** A minimal in-memory fake implementing the seven provider primitives. */
|
||||
/** A minimal in-memory fake implementing the eight provider primitives. */
|
||||
class FakeFileSystem extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
|
||||
@@ -30,6 +31,11 @@ class FakeFileSystem extends FileSystem {
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async lstat(path: string): Promise<FsPathInfo | undefined> {
|
||||
const content = this.files.get(path)
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND')
|
||||
@@ -120,6 +126,15 @@ describe('FileSystem provider seam', () => {
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lstat returns path metadata before resolving a target', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
fs.files.set('a.txt', 'hi')
|
||||
expect(await fs.lstat('a.txt')).toEqual({ version: 'v1', type: 'file', size: 2 })
|
||||
expect(await fs.lstat('missing.txt')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('branded id factories', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsPathInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
@@ -48,6 +49,11 @@ class FakeFs extends FileSystem {
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async lstat(path: string): Promise<FsPathInfo | undefined> {
|
||||
const content = this.files.get(`key:${path}`)
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
return this.files.get(target.targetKey) ?? ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user