feat(fs): add a minimal read_image tool over the attachment and fs seams
The model reads a PNG/JPEG/WebP/GIF file, the bytes commit through the durable attachment lifecycle, and the tool result carries the real ImageBlock so the image enters context from the next request onward. FileSystem gains a bounded readBytes primitive (local + E2B providers); registration is conditional on the attachment store, and a strict execution gate refuses routes that do not declare image input, so a text route's durable history stays free of image blocks. llm-replay models may declare inputModalities, letting keyless ACP snapshots pin both the sha256-referenced success and the verbatim refusal. Supersedes the withdrawn route-scoped design of PR #598; the decision record is .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md
|
||||
README.md: 7b993fa123d13833313ecf3f78c64e466b960d5d
|
||||
README.zh.md: 428719137f988395b76513eab2c3f76ce4f331e5
|
||||
README.md: 4b65bc3c5a5b31a8b4f96ec86e2239c2da680897
|
||||
README.zh.md: 45d9646f89623d2de106cd8e1dab38941c912977
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **local-filesystem implementation** of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eleven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
|
||||
The **local-filesystem implementation** of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)). Backs the twelve `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
|
||||
|
||||
```ts ignore-check
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
@@ -18,6 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`.
|
||||
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing.
|
||||
- **`readBytes`** — raw whole-file bytes with no decoding or binary rejection (the `read_image` tool validates content through the attachment service). The required byte cap short-circuits on the stat size before any content I/O; the subsequent stream reads at most one byte beyond the cap, so a file growing after stat still fails `FS_TOO_LARGE` without unbounded buffering.
|
||||
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a regular file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`, while a non-regular path entry is preserved and rejected with `FS_NOT_REGULAR_FILE`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十一个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。
|
||||
`ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十二个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。
|
||||
|
||||
```ts ignore-check
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
@@ -18,6 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **执行世界坐标**:`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。
|
||||
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。
|
||||
- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。
|
||||
- **`readBytes`**:按原始字节读取整个文件,不做解码或二进制拒绝(`read_image` 工具通过附件服务校验内容)。必填的字节上限在任何内容 I/O 之前先按 stat 大小短路;随后的流最多多读一个字节,因此 stat 之后增长的文件仍会以 `FS_TOO_LARGE` 失败,不会无界缓冲。
|
||||
- **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。
|
||||
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的普通文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;非普通路径条目也会被保留,并以 `FS_NOT_REGULAR_FILE` 拒绝;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。
|
||||
- **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface FsIoInternals {
|
||||
removeStagingDir?: (stagingDir: string) => Promise<void>
|
||||
/** Test hook after the temp file is written/synced but before final chmod+publication. */
|
||||
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
|
||||
/** Test hook after raw-read stat preflight and before bounded content I/O. */
|
||||
inspectReadBytesAfterStat?: (target: LocalTarget) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** A resolved local path: the absolute path shown to callers and its realpath identity. */
|
||||
@@ -379,6 +381,50 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal):
|
||||
return decodeUtf8(raw, 'read', target.displayPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a whole regular file as raw bytes with no decoding or binary rejection.
|
||||
* `maxBytes` bounds the complete content: the stat size short-circuits an
|
||||
* oversized file before any content I/O, and the stream reads at most one byte
|
||||
* beyond the cap so a file growing after stat cannot cause unbounded buffering.
|
||||
* @param target - the resolved file to read.
|
||||
* @param signal - aborts the read (`FS_ABORTED`).
|
||||
* @param maxBytes - inclusive byte cap on the complete content (`FS_TOO_LARGE`).
|
||||
* @param internals - test seam for a deterministic post-stat growth race.
|
||||
* @returns the full raw content, at most `maxBytes` long.
|
||||
*/
|
||||
export async function readWholeBytes(
|
||||
target: LocalTarget,
|
||||
signal: AbortSignal | undefined,
|
||||
maxBytes: number,
|
||||
internals: FsIoInternals = {},
|
||||
): Promise<Uint8Array> {
|
||||
const info = await statRegularFile(target, 'read', signal)
|
||||
if (info.size > maxBytes) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
|
||||
}
|
||||
await internals.inspectReadBytesAfterStat?.(target)
|
||||
const stream = createReadStream(target.targetKey, {
|
||||
end: maxBytes,
|
||||
...signal ? { signal } : {},
|
||||
})
|
||||
const chunks: Buffer[] = []
|
||||
let bytes = 0
|
||||
try {
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
bytes += chunk.length
|
||||
if (bytes > maxBytes) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */
|
||||
if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED')
|
||||
throw error
|
||||
}
|
||||
return Buffer.concat(chunks, bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
|
||||
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
probeNoFollow,
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeBytes,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
@@ -129,6 +130,10 @@ export class LocalFileSystem extends FileSystem {
|
||||
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
|
||||
}
|
||||
|
||||
override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
|
||||
return readWholeBytes({ displayPath: target.displayPath, targetKey: target.targetKey }, signal, maxBytes, this.internals)
|
||||
}
|
||||
|
||||
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
|
||||
const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
|
||||
return entries.map(entry => ({
|
||||
|
||||
@@ -238,6 +238,43 @@ describe('readText / streamText', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readBytes', () => {
|
||||
it('reads raw bytes without decoding or NUL rejection', async () => {
|
||||
const raw = Buffer.from([0x68, 0x00, 0x69, 0xff])
|
||||
await writeFile(join(dir, 'a.bin'), raw)
|
||||
expect(Buffer.from(await fs.readBytes(await fs.resolve('a.bin'), undefined, raw.length))).toEqual(raw)
|
||||
})
|
||||
|
||||
it('accepts a file exactly at maxBytes and rejects one past it', async () => {
|
||||
await writeFile(join(dir, 'a.bin'), Buffer.alloc(4, 1))
|
||||
const target = await fs.resolve('a.bin')
|
||||
expect((await fs.readBytes(target, undefined, 4)).length).toBe(4)
|
||||
await expect(fs.readBytes(target, undefined, 3)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' })
|
||||
})
|
||||
|
||||
it('bounds content I/O when a file grows after stat preflight', async () => {
|
||||
await writeFile(join(dir, 'a.bin'), Buffer.alloc(4, 1))
|
||||
const target = await fs.resolve('a.bin')
|
||||
fs.internals.inspectReadBytesAfterStat = () => writeFile(join(dir, 'a.bin'), Buffer.alloc(1024 * 1024, 2))
|
||||
|
||||
await expect(fs.readBytes(target, undefined, 4)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' })
|
||||
})
|
||||
|
||||
it('rejects a missing file and a directory', async () => {
|
||||
await expect(fs.readBytes(await fs.resolve('nope'), undefined, 1024)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(fs.readBytes(await fs.resolve('.'), undefined, 1024)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('reads under a live signal and rejects an already-aborted one with FS_ABORTED', async () => {
|
||||
await writeFile(join(dir, 'a.bin'), 'data')
|
||||
const live = new AbortController()
|
||||
expect((await fs.readBytes(await fs.resolve('a.bin'), live.signal, 1024)).length).toBe(4)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(fs.readBytes(await fs.resolve('a.bin'), controller.signal, 1024)).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDir', () => {
|
||||
it('lists files and directories in stable name order with resolved child targets', async () => {
|
||||
await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true })
|
||||
|
||||
Reference in New Issue
Block a user