refactor(tool-fs): keep image sniffing tool-local
This commit is contained in:
parent
8d337b2b32
commit
f3c69c2c3f
23 changed files with 141 additions and 201 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md
|
||||
2026-08-28-read-image-extensionless-attachment-paths.md: 0ca5a65ced68062612cff5494af811624188a7fb
|
||||
2026-08-28-read-image-extensionless-attachment-paths.zh.md: 547c62cd9adb631744e5e79306f390bd17c8932d
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.md
|
||||
2026-08-28-read-image-extensionless-paths.md: cf7d82b2ed86208bcb8ef6287dfcfc7c4391ccc4
|
||||
2026-08-28-read-image-extensionless-paths.zh.md: 8d4cd15f6c414bb28307863bfe61ea3a7181ed02
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# Agent Note: read_image accepts extension-less image paths
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-28-read-image-extensionless-paths.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`read_image` mapped `file_path` to a media type by extension alone and refused a path with no extension. Valid extension-less images therefore required a renamed copy before the model could inspect them. Normalized local attachment objects exposed to the model use content digests without extensions, so their published read-only paths triggered the same refusal.
|
||||
|
||||
## Decision
|
||||
|
||||
`read_image` treats a file extension as a media-type declaration. PNG, JPEG, WebP, and GIF extensions select their declared types; another non-empty extension is refused before filesystem I/O, and the attachment store's full decode rejects a declaration that does not match the bytes. A path with no extension is read through `ctx.fs` under the existing `maxImageBytes` and tighter `maxMessageImageBytes` cap, then a tool-local `sniffImageMediaType` helper identifies one of the four supported file signatures. The detected type passes through the same deployment media-type policy and `saveImage` admission, whose full decode remains authoritative. This narrows the sniffing rejection in [the minimal read_image tool note](../feature/2026-08-10-minimal-read-image-tool.md) to extension-bearing paths.
|
||||
|
||||
The mounted `ctx.fs` backend is the complete path-authorization authority for `read_image`. Extensions and file signatures decide only whether the tool accepts bytes that the backend returned. Any valid extension-less image readable through that backend can enter the current session, including a normalized attachment object; the tool performs no session-reference proof and the attachment service exposes no reverse path lookup.
|
||||
|
||||
Admission failures name the offending path. An extension-less mismatch names the signature that supplied the declaration, while unsupported bytes report no file content.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Export signature identification from the attachment Service Definition package.** Only `read_image` needs this pre-admission declaration. Publishing the helper would make one Consumer's filename policy part of the provider-independent attachment API while the store already owns authoritative decoding.
|
||||
|
||||
**Special-case normalized attachment object paths.** Resolving a path back to a Session reference would make two files readable through the same `ctx.fs` behave differently according to their origin and would leave ordinary extension-less images unsupported. Filesystem access remains the read authorization decision.
|
||||
|
||||
**Add extensions to stored attachment objects.** This would change the storage layout and every object-path consumer to satisfy one tool's media-type declaration rule.
|
||||
|
||||
## Consequences
|
||||
|
||||
The model can read ordinary extension-less images and normalized attachment paths directly in native and PTC modes. Wrong extensions retain their pre-I/O refusal and mismatch repair. A non-image path without an extension is read up to the image byte cap before rejection, and a normalized object re-enters source admission instead of bypassing the current deployment limits. The behavior changes only `dsh-tool-fs`; the attachment Service Definition and local provider keep their existing APIs and storage behavior.
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# Agent Note: read_image 接受无扩展名图片路径
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-28-read-image-extensionless-paths.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`read_image` 只按扩展名把 `file_path` 映射到媒体类型,并拒绝没有扩展名的路径。因此,模型必须先创建一份改名副本,才能查看合法的无扩展名图片。向模型公开的规范化本地附件对象以内容摘要命名,不带扩展名,所以其已发布的只读路径也会触发同一项拒绝。
|
||||
|
||||
## 决定
|
||||
|
||||
`read_image` 把文件扩展名视为媒体类型声明。PNG、JPEG、WebP 与 GIF 扩展名选择各自声明的类型;其他非空扩展名在文件系统 I/O 前被拒绝,附件存储的完整解码会拒绝与字节不匹配的声明。对于无扩展名路径,工具通过 `ctx.fs` 在既有 `maxImageBytes` 和更严格的 `maxMessageImageBytes` 上限内读取文件,再由工具内部的 `sniffImageMediaType` 辅助函数识别四种受支持的文件签名。识别结果经过同一套部署媒体类型策略和 `saveImage` 准入,后者的完整解码保持权威。这把[最小 read_image 工具 Agent Note](../feature/2026-08-10-minimal-read-image-tool.zh.md)中对嗅探的拒绝收窄到带扩展名的路径。
|
||||
|
||||
挂载的 `ctx.fs` 后端是 `read_image` 路径授权的完整依据。扩展名和文件签名只决定工具是否接受后端返回的字节。该后端可读的每个合法无扩展名图片都能进入当前会话,包括规范化附件对象;工具不证明 Session 引用,附件服务也不提供反向路径查找。
|
||||
|
||||
准入失败会指出出错路径。无扩展名路径的类型不匹配会指出提供声明的文件签名,而不受支持的字节不会出现在错误消息中。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**从附件 Service Definition 包导出文件签名识别。** 只有 `read_image` 需要这项准入前声明。公开该辅助函数会把单个消费方的文件名策略加入提供方无关的附件 API,而存储已经负责权威解码。
|
||||
|
||||
**特殊处理规范化附件对象路径。** 把路径反查为 Session 引用,会使 `ctx.fs` 以相同方式提供的两个文件根据来源产生不同读取结果,而且普通无扩展名图片仍然不受支持。文件系统访问保持读取授权决定。
|
||||
|
||||
**为存储的附件对象增加扩展名。** 这会为了满足一个工具的媒体类型声明规则而修改存储布局和每个对象路径消费方。
|
||||
|
||||
## 影响
|
||||
|
||||
模型可以在 native 和 PTC 模式下直接读取普通无扩展名图片与规范化附件路径。错误扩展名保留 I/O 前拒绝和类型不匹配修复提示。无扩展名非图片路径会在拒绝前读取到图片字节上限,规范化对象也会重新经过来源准入,而不会绕过当前部署限额。行为改动只位于 `dsh-tool-fs`;附件 Service Definition 与本地提供方保持现有 API 和存储行为。
|
||||
|
|
@ -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 .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md
|
||||
2026-08-10-minimal-read-image-tool.md: 0bf3e3ca79316f15727db1708b5f68ccb3d2d368
|
||||
2026-08-10-minimal-read-image-tool.zh.md: 130849991491a39200d8e2dda0e4dc9c1f166fb6
|
||||
2026-08-10-minimal-read-image-tool.md: be7c24965ee256e4062b5caf32ce7f8c62d9c1ae
|
||||
2026-08-10-minimal-read-image-tool.zh.md: f1d09a320e0f44145e4c8681668996d66fbb4898
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged
|
|||
|
||||
- **PR #598's route-scoped design** used a request-ready extension point, per-route schema visibility, reversible projection, and three durable concepts. Shared LLM request projection now handles text-only routes without putting tool registration or session formats into agent-loop.
|
||||
- **`agent.inject()` instead of the image-bearing tool result** — routes the image around the tool result as a separate injected user message. Rejected: the image *is* the tool's result; splitting them adds a second logged message with no gain, and the tool-result path already works end to end.
|
||||
- **Magic-byte sniffing instead of extension declaration** — sniffing duplicates detection the attachment store already owns (sharp-backed, authoritative). The extension is only a *declaration*; a mismatch fails closed with a rename remedy rather than being silently accepted, which also keeps the model's mental map (file name ↔ content) honest. This rejection covers extension-bearing paths; [extension-less attachment object paths](2026-08-28-read-image-extensionless-attachment-paths.md) narrows it — a path that declares nothing is identified from its file signature.
|
||||
- **Magic-byte sniffing instead of extension declaration** — sniffing duplicates detection the attachment store already owns (sharp-backed, authoritative). The extension is only a *declaration*; a mismatch fails closed with a rename remedy rather than being silently accepted, which also keeps the model's mental map (file name ↔ content) honest. This rejection covers extension-bearing paths; [extension-less image paths](../bug-fix/2026-08-28-read-image-extensionless-paths.md) narrows it — a path that declares nothing is identified from its file signature.
|
||||
- **Registering unconditionally and failing on a missing store** — rejected; a deployment without an attachment store cannot ever satisfy the tool, so its schema would be a standing lie. The route gate, by contrast, is per-call state and correctly lives at the execution boundary.
|
||||
|
||||
## Consequences
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Status: implemented
|
|||
|
||||
- **PR #598 的路由作用域设计**使用 request-ready 扩展点、按路由控制 schema 可见性、可逆投影和三个持久概念。共享 LLM 请求投影现在可以处理纯文本路由,无需把工具注册或会话格式放进 agent-loop。
|
||||
- **用 `agent.inject()` 代替带图像的工具结果**——把图像绕过工具结果,作为单独注入的用户消息。拒绝:图像就是工具的结果;拆开只会多一条无收益的日志消息,而工具结果路径本就端到端可用。
|
||||
- **用魔数嗅探代替扩展名声明**——嗅探重复了附件存储已拥有的检测(基于 sharp,权威)。扩展名只是声明;不匹配时按改名修复提示失败关闭,而不是被静默接受,这也让模型对文件名与内容的对应保持诚实。这一拒绝覆盖带扩展名的路径;[无扩展名附件对象路径](2026-08-28-read-image-extensionless-attachment-paths.zh.md)将其收窄,什么也没声明的路径按文件签名识别。
|
||||
- **用魔数嗅探代替扩展名声明**——嗅探重复了附件存储已拥有的检测(基于 sharp,权威)。扩展名只是声明;不匹配时按改名修复提示失败关闭,而不是被静默接受,这也让模型对文件名与内容的对应保持诚实。这一拒绝覆盖带扩展名的路径;[无扩展名图片路径](../bug-fix/2026-08-28-read-image-extensionless-paths.zh.md)将其收窄,什么也没声明的路径按文件签名识别。
|
||||
- **无条件注册、缺存储时执行报错**——拒绝;没有附件存储的部署永远无法满足该工具,其 schema 会是常态谎言。相反,路由门禁是逐调用状态,正确的位置就是执行边界。
|
||||
|
||||
## 后果
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
# Agent Note: read_image accepts extension-less attachment object paths
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-28-read-image-extensionless-attachment-paths.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Model-visible image descriptors name the normalized attachment's local read-only path, and normalized objects are content-addressed files without an image extension. `read_image` mapped `file_path` to a media type by extension alone and refused everything else, so passing the descriptor's own path back produced `read_image only accepts PNG/JPEG/WebP/GIF paths` for an image the store had already validated and persisted. The model's only workarounds were copying the object to a renamed file or re-uploading it.
|
||||
|
||||
## Decision
|
||||
|
||||
The extension stays the declared media type when it names one of the four supported formats, and every existing gate and diagnostic on that branch is unchanged. For a path without an extension the tool reads the bytes under the existing byte cap and identifies the container with `sniffImageMediaType`, a pure file-signature helper exported by the attachment Service Definition package. The sniffed type then passes through the same deployment media-type policy and `saveImage` admission, so the store's full decode remains the authority; a path whose extension names a non-image format is refused before any I/O. This narrows the sniffing rejection in [the minimal read_image tool note](2026-08-10-minimal-read-image-tool.md) to extension-bearing paths: an extension is still a declaration that fails closed on mismatch, while a path that declares nothing is identified from its bytes.
|
||||
|
||||
Reading an object path re-saves its bytes rather than short-circuiting through a reverse path lookup. Normalization passes an already-normalized image through byte-identically, so the re-save deduplicates to the same content-addressed reference; a store test pins that idempotency for a re-encoded object. No reference proof is required at the tool: object paths are unguessable content digests, the published objects are already readable through the mounted filesystem (Bash included), and the session-reference gate continues to protect the remote client RPC, which is the boundary where an attachment id alone grants bytes.
|
||||
|
||||
Two diagnostics are sharpened alongside: `INVALID_IMAGE` admission failures now name the offending path instead of surfacing the store's bare message, and an extension-less admission mismatch blames the file signature rather than a nonexistent extension.
|
||||
|
||||
## Consequences
|
||||
|
||||
The model reads a descriptor's normalized attachment path directly, in native and PTC modes, without copying, renaming, or re-uploading; recorded scenarios `read-image-attachment-path` and `ptc-read-image-attachment-path` pin both flows end to end, including deduplication to the stored reference. Ordinary extension-less image files become readable through the same content identification, while a wrong-extension path keeps its fast pre-I/O refusal and repair message. Any future consumer accepting extension-less image paths can reuse `sniffImageMediaType` instead of a second signature table.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Name objects with an extension on disk.** This changes the storage layout, dedup commit path, corrupt checks, export archive, and every committed object-path expectation, only to satisfy the tool's extension heuristic, and the heuristic itself — extension as media-type authority — stays wrong. The store already treats decoded content as the authority.
|
||||
|
||||
**Instruct the model to copy the object before reading.** Prompt guidance does not stop direct calls, so the misleading refusal survives; the copy adds a filesystem write and a turn for a pure read; and the re-read commits the identical bytes to the same reference anyway, so the copy changes nothing but cost.
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
# Agent Note: read_image 接受无扩展名的附件对象路径
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-28-read-image-extensionless-attachment-paths.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
模型可见的图片描述会给出规范化附件的本地只读路径,而规范化对象是不带图片扩展名的内容寻址文件。`read_image` 仅凭扩展名把 `file_path` 映射到媒体类型并拒绝其余一切,于是把描述里的路径原样传回会得到 `read_image only accepts PNG/JPEG/WebP/GIF paths`,尽管该图片已经通过存储校验并持久保存。模型只能把对象复制成改名文件或要求重新上传。
|
||||
|
||||
## 决定
|
||||
|
||||
扩展名命名四种受支持格式之一时仍作为声明的媒体类型,该分支的所有既有检查和诊断保持不变。对无扩展名的路径,工具在既有字节上限内读取字节,用 `sniffImageMediaType` 识别容器格式,这是附件 Service Definition 包导出的纯文件签名辅助函数。识别出的类型随后经过同一套部署媒体类型政策和 `saveImage` 准入,存储实现的完整解码仍是权威;扩展名声明了非图片格式的路径在任何 I/O 之前被拒绝。这把[最小 read_image 工具笔记](2026-08-10-minimal-read-image-tool.zh.md)中对嗅探的拒绝收窄到带扩展名的路径:扩展名仍是不匹配即失败的声明,而什么也没声明的路径按其字节识别。
|
||||
|
||||
读取对象路径会重新保存其字节,而不是通过反向路径查找走捷径。归一化让已归一化的图片字节原样通过,因此重新保存会去重到同一个内容寻址引用;一个存储测试固定了重编码对象的这一幂等性。工具处不需要会话引用证明:对象路径是不可猜测的内容摘要,已发布对象本来就能通过挂载的文件系统(包括 Bash)读到,而会话引用检查继续保护远程客户端 RPC,那才是仅凭附件 id 就能获得字节的边界。
|
||||
|
||||
顺带收紧了两处诊断:`INVALID_IMAGE` 准入失败现在会指出出错的路径而不是透传存储的裸消息,无扩展名路径的准入不匹配归因于文件签名而不是不存在的扩展名。
|
||||
|
||||
## 影响
|
||||
|
||||
模型在 native 和 PTC 两种模式下都可以直接读取描述中的规范化附件路径,无需复制、改名或重新上传;录制场景 `read-image-attachment-path` 和 `ptc-read-image-attachment-path` 端到端固定了这两条流程,包括去重到已存储引用。普通的无扩展名图片文件也通过同一套内容识别变得可读,而扩展名错误的路径保留 I/O 之前的快速拒绝和修复消息。将来任何接受无扩展名图片路径的消费方都可以复用 `sniffImageMediaType`,不必再维护一张签名表。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在磁盘上给对象命名加扩展名。** 这会改动存储布局、去重提交路径、损坏检查、导出归档以及每一处已提交的对象路径期望,只为迁就工具的扩展名启发式,而启发式本身(以扩展名为媒体类型权威)依然是错的。存储实现一直以解码内容为权威。
|
||||
|
||||
**指示模型先复制对象再读取。** 提示词挡不住直接调用,误导性的拒绝依然存在;复制为一次纯读取平添一次文件系统写入和一轮往返;重新读取本来就会把相同字节提交到同一引用,复制除了成本什么也不改变。
|
||||
|
|
@ -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 docs/subsystems/attachment.md
|
||||
attachment.md: e93e6782eae97811be2060807a867d3036931a8c
|
||||
attachment.zh.md: a0167cec6d1ddd8a06bdd7501edcb3d1c6ababe5
|
||||
attachment.md: 15daa2b8d541ba847d48f5c43a06c1c537df6d11
|
||||
attachment.zh.md: c74a18f6bec63e117afcdb141512be04f8d75f9a
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachmen
|
|||
|
||||
## Identity and verified metadata
|
||||
|
||||
`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:<digest>`, but consumers must neither parse that representation nor derive a filesystem path from it. A consumer may ask the attachment provider for its object location through `imageHostPath()`, then must use the current execution filesystem to decide whether model tools can read that host path. The model may pass such a path back to `read_image` directly: extension-less files are identified from their file signature, and re-saving a normalized object deduplicates to the same content-addressed reference.
|
||||
`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:<digest>`, but consumers must neither parse that representation nor derive a filesystem path from it. A consumer may ask the attachment provider for its object location through `imageHostPath()`, then must use the current execution filesystem to decide whether model tools can read that host path.
|
||||
|
||||
```ts type-equiv
|
||||
/** Raster image formats accepted by the version-one attachment path. */
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
## 标识与经过校验的元数据
|
||||
|
||||
`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:<digest>`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方可以通过 `imageHostPath()` 询问附件提供方所持对象的位置,然后必须由当前执行文件系统判断模型工具能否读取该宿主路径。模型可以把这样的路径直接传回 `read_image`:无扩展名文件按文件签名识别格式,重新保存规范化对象会去重到同一个内容寻址引用。
|
||||
`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:<digest>`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方可以通过 `imageHostPath()` 询问附件提供方所持对象的位置,然后必须由当前执行文件系统判断模型工具能否读取该宿主路径。
|
||||
|
||||
```ts type-equiv
|
||||
/** Raster image formats accepted by the version-one attachment path. */
|
||||
|
|
|
|||
|
|
@ -172,28 +172,6 @@ describe('local attachment store', () => {
|
|||
expect(String(saved.attachmentId)).toBe(`sha256:${createHash('sha256').update(read.data).digest('hex')}`)
|
||||
})
|
||||
|
||||
it('re-saves a re-encoded normalized object byte-identically to the same reference', async () => {
|
||||
const storageRoot = await root()
|
||||
const oversized = new Uint8Array(await sharp({
|
||||
create: { width: 4, height: 4, channels: 3, background: { r: 9, g: 9, b: 9 } },
|
||||
}).png().toBuffer())
|
||||
const policy = { maxPixels: POLICY.maxPixels, maxDimension: 2, maxBytes: 1024 * 1024 }
|
||||
const saved = await saveImageFile(storageRoot, {
|
||||
data: oversized, mediaType: 'image/png', name: 'big.png',
|
||||
}, { ...LIMITS, maxImagePixels: 64 }, policy)
|
||||
|
||||
// Normalization passes an already-normalized object through unchanged, so
|
||||
// reading the object file and saving those bytes again deduplicates: the
|
||||
// read_image tool can accept object paths without minting new attachments.
|
||||
const objectBytes = (await readImageFile(storageRoot, saved)).data
|
||||
const resaved = await saveImageFile(storageRoot, {
|
||||
data: objectBytes, mediaType: saved.mediaType, name: 'big',
|
||||
}, { ...LIMITS, maxImagePixels: 64 }, policy)
|
||||
expect(resaved.attachmentId).toBe(saved.attachmentId)
|
||||
expect(resaved.bytes).toBe(saved.bytes)
|
||||
expect(resaved.originalDimensions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps admitted history readable after deployment limits become stricter', async () => {
|
||||
const storageRoot = await root()
|
||||
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)
|
||||
|
|
|
|||
|
|
@ -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/attachment/attachment/README.md
|
||||
README.md: feb8b14a2a46543169820e444994ef7cb1514210
|
||||
README.zh.md: 0f088e170cde9fb27032351dc1f9861c9111b6fd
|
||||
README.md: 01a5d6ee143ff53175dd7327cd6d419af61938a3
|
||||
README.zh.md: 1a1d297297a6815ce5338391af0182e471f99000
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ This section explains the design decisions behind the seam and the service opera
|
|||
|
||||
### Service operations
|
||||
|
||||
The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. The pure `sniffImageMediaType` export identifies a supported container from its file signature for consumers that accept extension-less image paths; persisting callers keep the store's full decode authoritative. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts).
|
||||
The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts).
|
||||
|
||||
### Source map
|
||||
|
||||
|
|
@ -77,7 +77,6 @@ The service family runs one admission-and-storage flow: every entry point enforc
|
|||
| [`src/types.ts`](src/types.ts) | Durable vocabulary: references, limits, upload and store payloads |
|
||||
| [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`: canonical-base64 enforcement, then `saveImages` delegation |
|
||||
| [`src/error.ts`](src/error.ts) | `AttachmentError` class and the `isImageAdmissionError` runtime subset |
|
||||
| [`src/sniff.ts`](src/sniff.ts) | `sniffImageMediaType`: file-signature container identification |
|
||||
| [`src/brand.ts`](src/brand.ts) | `AttachmentId` branded opaque identifier |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; implementations enforce immutable-store checks) |
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ kind: "package-reference"
|
|||
|
||||
### 服务操作
|
||||
|
||||
服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。纯函数导出 `sniffImageMediaType` 按文件签名识别受支持的图片容器,供接受无扩展名图片路径的消费方使用;持久化调用方仍以存储实现的完整解码为权威。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。
|
||||
服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。
|
||||
|
||||
### 源码地图
|
||||
|
||||
|
|
@ -77,7 +77,6 @@ kind: "package-reference"
|
|||
| [`src/types.ts`](src/types.ts) | 持久词汇:引用、限额、上传与存储载荷 |
|
||||
| [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`:规范 base64 强制,随后委托 `saveImages` |
|
||||
| [`src/error.ts`](src/error.ts) | `AttachmentError` 类与 `isImageAdmissionError` 运行时子集 |
|
||||
| [`src/sniff.ts`](src/sniff.ts) | `sniffImageMediaType`:按文件签名识别图片容器 |
|
||||
| [`src/brand.ts`](src/brand.ts) | `AttachmentId` 带类型标记的不透明标识符 |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;实现负责强制不可变存储检查) |
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ export { AttachmentError, isImageAdmissionError } from './error.ts'
|
|||
export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
|
||||
export { admitEncodedImages } from './admission.ts'
|
||||
export { requestImageDimensions } from './request-projection.ts'
|
||||
export { sniffImageMediaType } from './sniff.ts'
|
||||
export type {
|
||||
AttachmentId as AttachmentIdType,
|
||||
EncodedImageAttachment,
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* Leading-byte image-format identification for consumers that accept image
|
||||
* files without a media-type-bearing file name, such as normalized attachment
|
||||
* object paths. The result names the container the signature claims; callers
|
||||
* that persist bytes keep the attachment service's full decode authoritative.
|
||||
* @module @deepseek-ai/dsh-attachment/src/sniff
|
||||
*/
|
||||
|
||||
import type { ImageMediaType } from './types.ts'
|
||||
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const
|
||||
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff] as const
|
||||
|
||||
function matchesBytes(data: Uint8Array, offset: number, expected: readonly number[]): boolean {
|
||||
if (data.byteLength < offset + expected.length) return false
|
||||
return expected.every((byte, index) => data[offset + index] === byte)
|
||||
}
|
||||
|
||||
function matchesAscii(data: Uint8Array, offset: number, text: string): boolean {
|
||||
if (data.byteLength < offset + text.length) return false
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (data[offset + index] !== text.charCodeAt(index)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify a supported image container from its file signature.
|
||||
* @param data - the leading file bytes; passing the complete file is fine.
|
||||
* @returns the media type the signature claims, or undefined when the bytes
|
||||
* carry no complete PNG/JPEG/WebP/GIF signature.
|
||||
*/
|
||||
export function sniffImageMediaType(data: Uint8Array): ImageMediaType | undefined {
|
||||
if (matchesBytes(data, 0, PNG_SIGNATURE)) return 'image/png'
|
||||
if (matchesBytes(data, 0, JPEG_SIGNATURE)) return 'image/jpeg'
|
||||
if (matchesAscii(data, 0, 'GIF87a') || matchesAscii(data, 0, 'GIF89a')) return 'image/gif'
|
||||
if (matchesAscii(data, 0, 'RIFF') && matchesAscii(data, 8, 'WEBP')) return 'image/webp'
|
||||
return undefined
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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/tool-fs/README.md
|
||||
README.md: 31e7b4c900cbd6cb232dac229eb93abde88b6042
|
||||
README.zh.md: c962f0028d56eb52e577828bf7b3e56a90b4e9d1
|
||||
README.md: f00c96d18619e0fb59609eb66729ebfce445dbc6
|
||||
README.zh.md: 5915e5a07de10775469d0ebe301564948b357569
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
|||
|
||||
### Policy and sandbox behavior
|
||||
|
||||
Path authorization for `read` and `read_image` belongs entirely to `ctx.fs`; media-type declarations and file signatures only decide whether `read_image` accepts the bytes returned by that backend.
|
||||
|
||||
With the policy plugin mounted, `write` and `edit` obtain their guard from the `fs/*` intent slots, so an unread target or a stale observation fails with `FS_NOT_OBSERVED` or `FS_STALE_VERSION` and a recovery instruction. Under a confining backend (`fs-sandbox`), `write`/`edit` additionally advertise `sandbox_permissions` and `justification`; a denied mutation returns the `[sandbox: file access denied under <mode> mode]` marker with the same-turn escalation hint, and an approved retry may stamp a strictly wider mode for that one call.
|
||||
|
||||
### Failures and recovery
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ kind: "package-reference"
|
|||
|
||||
### 策略与沙箱行为
|
||||
|
||||
`read` 与 `read_image` 的路径授权完全由 `ctx.fs` 负责;媒体类型声明和文件签名只决定 `read_image` 是否接受该后端返回的字节。
|
||||
|
||||
挂载策略插件后,`write` 与 `edit` 从 `fs/*` 意图槽位取得防护,因此未读目标或陈旧观察会以 `FS_NOT_OBSERVED` 或 `FS_STALE_VERSION` 及恢复指令失败。使用施加沙箱限制的后端(`fs-sandbox`)时,`write`/`edit` 还会公开 `sandbox_permissions` 与 `justification`;被拒绝的变更返回 `[sandbox: file access denied under <mode> mode]` 标记与同轮次升级提示,获批的重试可以在该次调用中加盖严格更宽的模式。
|
||||
|
||||
### 失败与恢复
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* The model-facing `read_image` tool commits a PNG/JPEG/WebP/GIF file. A path
|
||||
* without a file extension — normalized attachment objects are named by their
|
||||
* content digest alone — is accepted too: its format comes from the file
|
||||
* signature, and the attachment service's full decode stays authoritative.
|
||||
* without a file extension is identified from its file signature, while the
|
||||
* attachment service's full decode stays authoritative. The mounted `ctx.fs`
|
||||
* backend owns path resolution and read access; names only declare media type.
|
||||
*
|
||||
* The route gate is deliberately stricter than the host upload preflight. An
|
||||
* image-reading tool is useful only when the exact calling route can inspect
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
import { basename, extname } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { AttachmentError, AttachmentId, sniffImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
|
@ -30,6 +30,35 @@ const IMAGE_EXTENSIONS: Readonly<Record<string, ImageMediaType>> = {
|
|||
'.gif': 'image/gif',
|
||||
}
|
||||
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const
|
||||
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff] as const
|
||||
|
||||
function matchesBytes(data: Uint8Array, offset: number, expected: readonly number[]): boolean {
|
||||
if (data.byteLength < offset + expected.length) return false
|
||||
return expected.every((byte, index) => data[offset + index] === byte)
|
||||
}
|
||||
|
||||
function matchesAscii(data: Uint8Array, offset: number, value: string): boolean {
|
||||
if (data.byteLength < offset + value.length) return false
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (data[offset + index] !== value.charCodeAt(index)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the media type declared by a supported image file signature.
|
||||
* @param data - file bytes read through the current filesystem backend.
|
||||
* @returns the detected supported media type, or undefined for other bytes.
|
||||
*/
|
||||
export function sniffImageMediaType(data: Uint8Array): ImageMediaType | undefined {
|
||||
if (matchesBytes(data, 0, PNG_SIGNATURE)) return 'image/png'
|
||||
if (matchesBytes(data, 0, JPEG_SIGNATURE)) return 'image/jpeg'
|
||||
if (matchesAscii(data, 0, 'GIF87a') || matchesAscii(data, 0, 'GIF89a')) return 'image/gif'
|
||||
if (matchesAscii(data, 0, 'RIFF') && matchesAscii(data, 8, 'WEBP')) return 'image/webp'
|
||||
return undefined
|
||||
}
|
||||
|
||||
const IMAGE_VALUE_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
|
@ -264,7 +293,7 @@ export function applyReadImageTool(ctx: Context): void {
|
|||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (error.code === 'INVALID_IMAGE') {
|
||||
if (error.code === 'INVALID_IMAGE' && declared === undefined) {
|
||||
throw new Error(
|
||||
`cannot read "${target.displayPath}": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`,
|
||||
{ cause: error },
|
||||
|
|
|
|||
|
|
@ -29,16 +29,13 @@ import {
|
|||
formatImageReadOutput,
|
||||
imageMediaTypeForPath,
|
||||
imageRefFromValue,
|
||||
sniffImageMediaType,
|
||||
} from '../src/read-image.ts'
|
||||
|
||||
/** 1x1 red PNG (valid signature, IHDR, IDAT). */
|
||||
const PNG_1X1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')
|
||||
/** 3x3 red PNG used to trip a tiny configured pixel limit. */
|
||||
const PNG_3X3 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAEElEQVR4nGP4z8AAQQxYWACPjgj4kWPEuQAAAABJRU5ErkJggg==', 'base64')
|
||||
/** 1x1 red JPEG already inside every normalization limit (byte-identical passthrough). */
|
||||
const JPEG_1X1 = Buffer.from('/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABgj/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABykX//Z', 'base64')
|
||||
/** 1x1 red WebP already inside every normalization limit (byte-identical passthrough). */
|
||||
const WEBP_1X1 = Buffer.from('UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA=', 'base64')
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
|
|
@ -170,6 +167,30 @@ describe('imageMediaTypeForPath', () => {
|
|||
})
|
||||
})
|
||||
|
||||
function ascii(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value)
|
||||
}
|
||||
|
||||
describe('sniffImageMediaType', () => {
|
||||
it('identifies each supported container from its complete signature', () => {
|
||||
expect(sniffImageMediaType(Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]))).toBe('image/png')
|
||||
expect(sniffImageMediaType(Uint8Array.from([0xff, 0xd8, 0xff, 0xe0]))).toBe('image/jpeg')
|
||||
expect(sniffImageMediaType(ascii('GIF87a...'))).toBe('image/gif')
|
||||
expect(sniffImageMediaType(ascii('GIF89a...'))).toBe('image/gif')
|
||||
expect(sniffImageMediaType(ascii('RIFF\0\0\0\0WEBPVP8 '))).toBe('image/webp')
|
||||
})
|
||||
|
||||
it('returns undefined for other bytes, incomplete signatures, and non-WebP RIFF containers', () => {
|
||||
expect(sniffImageMediaType(new Uint8Array())).toBeUndefined()
|
||||
expect(sniffImageMediaType(ascii('plain text'))).toBeUndefined()
|
||||
expect(sniffImageMediaType(Uint8Array.from([0x89, 0x50, 0x4e]))).toBeUndefined()
|
||||
expect(sniffImageMediaType(Uint8Array.from([0xff, 0xd8]))).toBeUndefined()
|
||||
expect(sniffImageMediaType(ascii('GIF90a'))).toBeUndefined()
|
||||
expect(sniffImageMediaType(ascii('RIFF\0\0\0\0WAVE'))).toBeUndefined()
|
||||
expect(sniffImageMediaType(ascii('RIFF\0\0\0'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('imageRefFromValue', () => {
|
||||
it('re-brands with and without the optional display name', () => {
|
||||
const base = { attachmentId: 'sha256:00', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }
|
||||
|
|
@ -291,44 +312,6 @@ describe('extension-less paths', () => {
|
|||
expect(text(second)).toContain(`<path>${objectPath}</path>`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['image/jpeg', JPEG_1X1],
|
||||
['image/webp', WEBP_1X1],
|
||||
] as const)('dedups a re-read %s object to its stored reference', async (mediaType, bytes) => {
|
||||
const ctx = await setup()
|
||||
const attachments = mountedStore(ctx)
|
||||
const ref = await attachments.saveImage({ data: bytes, mediaType, name: 'source' })
|
||||
const result = await readImage(ctx, { file_path: objectPathOf(attachments, ref) }, agentOn('vision-model'))
|
||||
expect(result.isError).toBe(false)
|
||||
const reread = (result.content[1] as { attachment: ImageAttachmentRef }).attachment
|
||||
expect(reread.attachmentId).toBe(ref.attachmentId)
|
||||
expect(reread.mediaType).toBe(mediaType)
|
||||
})
|
||||
|
||||
it('forwards an attachment object path read through the outer run_code context', async () => {
|
||||
const ctx = await setup({ toolMode: 'ptc' })
|
||||
const attachments = mountedStore(ctx)
|
||||
const ref = await attachments.saveImage({ data: PNG_1X1, mediaType: 'image/png', name: 'red.png' })
|
||||
const objectPath = objectPathOf(attachments, ref)
|
||||
const runtime = ctx.codeRuntime as FakeRuntime
|
||||
runtime.behavior = async (request) => {
|
||||
const value = await request.bindings[0]!.functions.read_image!({ file_path: objectPath })
|
||||
return { logs: [], value }
|
||||
}
|
||||
|
||||
const result = await call(ctx, RUN_CODE_NAME, {
|
||||
code: 'return await tools.read_image({ file_path: attachmentPath })',
|
||||
description: 'Read the attachment object through PTC mode',
|
||||
}, agentOn('vision-model'))
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const forwarded = result.additionalContexts?.[0]?.content
|
||||
expect(forwarded?.[1]).toMatchObject({
|
||||
type: 'image',
|
||||
attachment: { attachmentId: ref.attachmentId, mediaType: 'image/png' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reads an ordinary extension-less image file by sniffing its content', async () => {
|
||||
await writeFile(join(dir, 'avatar'), PNG_1X1)
|
||||
const ctx = await setup()
|
||||
|
|
@ -369,14 +352,6 @@ describe('extension-less paths', () => {
|
|||
expect(text(result)).toContain('do not decode as a supported PNG/JPEG/WebP/GIF image')
|
||||
})
|
||||
|
||||
it('reports a missing attachment object through the fs vocabulary', async () => {
|
||||
const ctx = await setup()
|
||||
const bogus = join(home, 'attachments', 'v1', 'objects', 'ab', 'ab'.repeat(32))
|
||||
const result = await readImage(ctx, { file_path: bogus }, agentOn('vision-model'))
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('not found')
|
||||
})
|
||||
|
||||
it('applies the deployment media-type policy to the sniffed format', async () => {
|
||||
/** Store whose deployment accepts JPEG only; sniffed PNG bytes must refuse before any save. */
|
||||
class JpegOnlySniffStore extends AttachmentStore {
|
||||
|
|
@ -561,18 +536,10 @@ describe('image admission failures', () => {
|
|||
expect(text(result)).toContain('rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats')
|
||||
})
|
||||
|
||||
it('explains a named image file whose bytes do not decode', async () => {
|
||||
await writeFile(join(dir, 'broken.png'), PNG_1X1.subarray(0, 16))
|
||||
const ctx = await setup()
|
||||
const result = await readImage(ctx, { file_path: 'broken.png' }, agentOn('vision-model'))
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(`cannot read "${join(dir, 'broken.png')}": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image`)
|
||||
})
|
||||
|
||||
it('fails with FS_TOO_LARGE before reading a file past maxImageBytes', async () => {
|
||||
await writeFile(join(dir, 'red.png'), PNG_1X1)
|
||||
it('caps an extension-less read at maxImageBytes before format detection', async () => {
|
||||
await writeFile(join(dir, 'red'), PNG_1X1)
|
||||
const ctx = await setup({ storeConfig: { maxImageBytes: PNG_1X1.length - 1 } })
|
||||
const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
|
||||
const result = await readImage(ctx, { file_path: 'red' }, agentOn('vision-model'))
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('exceeds')
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue