mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix: address ds-review-bot v7 findings on the merged image-input head
- gate model selection on steering-placement image carriers from enqueue until their steering/message event publishes; release the gate when an admission ends idle without publication (both behaviorally asserted) - reject session.updateQueue edits carrying non-text blocks at the RPC boundary (queue edits cannot bypass image admission) - extend the durable-directory walk past a first-created DSH_HOME to the deepest pre-existing ancestor - strip Windows-style separators from attachment display names on POSIX - verify attachment reads with a header-only probe (digest already proves the bytes decoded fully at admission); document the read path - make SessionInputShell.addImages refusal observable and keep workspace transfers/composer intake from leaking refused drafts - own ONE recursive image walk (dsh-llm contentHasImage) across apiproxy, pi-ai, compact-basic, and the DeepSeek text-only assertion - drop the redundant canonical-base64 regex and the no-op role read - move AttachmentId/AttachmentError out of types.ts (brand.ts/error.ts); document why AttachmentError does not extend HarnessError - document the hard attachments inject in both consumer READMEs
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/** Raster decoding used before bytes enter durable storage. */
|
||||
/** Raster inspection: full decode at admission, header-only probe on verified reads. */
|
||||
|
||||
import sharp from 'sharp'
|
||||
import sharp, { type Sharp } from 'sharp'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
|
||||
@@ -18,26 +18,47 @@ const MEDIA_TYPES: Readonly<Record<string, ImageMediaType>> = {
|
||||
gif: 'image/gif',
|
||||
}
|
||||
|
||||
async function imageMetadata(image: Sharp): Promise<DetectedImage> {
|
||||
const metadata = await image.metadata()
|
||||
const mediaType = MEDIA_TYPES[metadata.format as string]
|
||||
if (mediaType === undefined) {
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE')
|
||||
}
|
||||
return { mediaType, width: metadata.width, height: metadata.height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a supported raster and return its intrinsic metadata.
|
||||
* Parse a supported raster's header and return its intrinsic metadata without
|
||||
* decoding pixels. Digest-verified reads use this: admission already proved
|
||||
* that these exact bytes decode completely, so the read path only re-derives
|
||||
* the reference fields instead of paying the full-raster decode again.
|
||||
* @param data - complete encoded image bytes.
|
||||
* @param maxPixels - optional write-time decoded-pixel limit; reads omit it.
|
||||
* @returns verified format and dimensions.
|
||||
*/
|
||||
export async function detectImage(data: Uint8Array, maxPixels?: number): Promise<DetectedImage> {
|
||||
export async function probeImage(data: Uint8Array): Promise<DetectedImage> {
|
||||
try {
|
||||
const image = sharp(data, { failOn: 'error', limitInputPixels: false })
|
||||
const metadata = await image.metadata()
|
||||
const mediaType = MEDIA_TYPES[metadata.format as string]
|
||||
if (mediaType === undefined) {
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE')
|
||||
}
|
||||
const { width, height } = metadata
|
||||
if (maxPixels !== undefined && width * height > maxPixels) {
|
||||
throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
|
||||
}
|
||||
await image.raw().toBuffer()
|
||||
return { mediaType, width, height }
|
||||
return await imageMetadata(sharp(data, { failOn: 'error', limitInputPixels: false }))
|
||||
} catch (error) {
|
||||
if (error instanceof AttachmentError) throw error
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully decode a supported raster and return its intrinsic metadata.
|
||||
* @param data - complete encoded image bytes.
|
||||
* @param maxPixels - decoded-pixel admission limit.
|
||||
* @returns verified format and dimensions.
|
||||
*/
|
||||
export async function detectImage(data: Uint8Array, maxPixels?: number): Promise<DetectedImage> {
|
||||
try {
|
||||
const image = sharp(data, { failOn: 'error', limitInputPixels: false })
|
||||
const detected = await imageMetadata(image)
|
||||
if (maxPixels !== undefined && detected.width * detected.height > maxPixels) {
|
||||
throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
|
||||
}
|
||||
await image.raw().toBuffer()
|
||||
return detected
|
||||
} catch (error) {
|
||||
if (error instanceof AttachmentError) throw error
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error })
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { constants } from 'node:fs'
|
||||
import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { chmod, link, mkdir, open, readFile, stat, unlink } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import {
|
||||
AttachmentError,
|
||||
AttachmentId,
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
SaveImageAttachment,
|
||||
StoredImageAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import { detectImage } from './image.ts'
|
||||
import { detectImage, probeImage } from './image.ts'
|
||||
|
||||
const ID_PATTERN = /^sha256:([a-f0-9]{64})$/
|
||||
|
||||
@@ -24,7 +24,11 @@ function digest(data: Uint8Array): string {
|
||||
|
||||
function displayName(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const clean = basename(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255)
|
||||
// Strip both separator styles by hand: a POSIX host treats `\` as an
|
||||
// ordinary character, so path.basename would keep a Windows client's full
|
||||
// local path and leak it into the reference and the session log.
|
||||
const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1)
|
||||
const clean = leaf.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255)
|
||||
return clean === '' ? undefined : clean
|
||||
}
|
||||
|
||||
@@ -79,6 +83,31 @@ async function syncDirectory(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from a preferred boundary to the deepest ancestor that already
|
||||
* exists. A first save may create DSH_HOME itself (recursive mkdir), and a
|
||||
* directory this process creates is not durable until its parent entry syncs
|
||||
* — so only a pre-existing directory may be vouched as the durable stop.
|
||||
* @param path - preferred absolute boundary.
|
||||
* @returns `path` when it exists, else its closest existing ancestor.
|
||||
*/
|
||||
async function existingBoundary(path: string): Promise<string> {
|
||||
let level = resolve(path)
|
||||
while (true) {
|
||||
try {
|
||||
await stat(level)
|
||||
return level
|
||||
} catch {
|
||||
// Swallows only the stat probe's failure: a missing (or unreadable)
|
||||
// level simply moves the boundary up; mkdir later surfaces real errors.
|
||||
}
|
||||
const parent = dirname(level)
|
||||
/* v8 ignore next -- filesystem-root guard: the root directory always exists, so stat returns first. */
|
||||
if (parent === level) return level
|
||||
level = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one private directory tree and persist every ancestor entry up to a
|
||||
* caller-vouched durable boundary. The walk deliberately ignores what mkdir
|
||||
@@ -118,10 +147,12 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
|
||||
const sha256 = digest(input.data)
|
||||
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
||||
const staging = join(root, 'tmp')
|
||||
// The durable boundary is the root's grandparent (DSH_HOME for the
|
||||
// The preferred boundary is the root's grandparent (DSH_HOME for the
|
||||
// documented `DSH_HOME/attachments/v1` layout): `attachments`/`v1` may be
|
||||
// first-created by a concurrent save, so their entries sync on every path.
|
||||
const boundary = dirname(dirname(resolve(root)))
|
||||
// When DSH_HOME itself does not exist yet, the boundary retreats to its
|
||||
// closest existing ancestor so the first save syncs the new home entry too.
|
||||
const boundary = await existingBoundary(dirname(dirname(resolve(root))))
|
||||
await ensureDurableDirectory(bucket, boundary)
|
||||
await ensureDurableDirectory(staging, boundary)
|
||||
const temporary = join(staging, randomUUID())
|
||||
@@ -188,8 +219,12 @@ export async function readImageFile(root: string, ref: ImageAttachmentRef): Prom
|
||||
throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
|
||||
}
|
||||
if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
|
||||
const metadata = await inspectMetadata(data, ref.mediaType)
|
||||
if (metadata.bytes !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) {
|
||||
// The digest proves these are the exact bytes admission fully decoded, so
|
||||
// the read path only re-derives the header fields (no raster decode, no
|
||||
// per-request pixel amplification on history replay).
|
||||
const metadata = await probeImage(data)
|
||||
if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes
|
||||
|| metadata.width !== ref.width || metadata.height !== ref.height) {
|
||||
throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')
|
||||
}
|
||||
return { ref, data }
|
||||
|
||||
15
packages/attachment/attachment/src/brand.ts
Normal file
15
packages/attachment/attachment/src/brand.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Attachment identifier brand. @module @deepseek-ai/dsh-attachment/brand */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Opaque content-addressed identifier for one immutable attachment object. */
|
||||
export type AttachmentId = Branded<'AttachmentId'>
|
||||
|
||||
/**
|
||||
* Brand a validated storage identifier.
|
||||
* @param value - backend-produced opaque identifier.
|
||||
* @returns the branded identifier.
|
||||
*/
|
||||
export function AttachmentId(value: string): AttachmentId {
|
||||
return value as AttachmentId
|
||||
}
|
||||
26
packages/attachment/attachment/src/error.ts
Normal file
26
packages/attachment/attachment/src/error.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */
|
||||
|
||||
/**
|
||||
* Stable failures suitable for host RPC error mapping.
|
||||
*
|
||||
* Deliberately re-implements the `HarnessError` shape instead of extending it:
|
||||
* the base lives in `@deepseek-ai/dsh-llm`, which itself depends on this
|
||||
* package (`ImageBlock` references `ImageAttachmentRef`), so sharing the base
|
||||
* would create a dependency cycle. Consumers route on `code`, never on the
|
||||
* prototype chain, so the shapes stay interchangeable at the wire boundary.
|
||||
*/
|
||||
export class AttachmentError extends Error {
|
||||
/** Stable machine-routing failure code. */
|
||||
readonly code: string
|
||||
|
||||
/**
|
||||
* @param message - human-readable failure description without raw bytes or host paths.
|
||||
* @param code - stable machine-routing code.
|
||||
* @param options - optional chained cause.
|
||||
*/
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'AttachmentError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
StoredImageAttachment,
|
||||
} from './types.ts'
|
||||
|
||||
export { AttachmentError, AttachmentId } from './types.ts'
|
||||
export { AttachmentId } from './brand.ts'
|
||||
export { AttachmentError } from './error.ts'
|
||||
export type {
|
||||
AttachmentId as AttachmentIdType,
|
||||
ImageAttachmentLimits,
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
/** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { AttachmentId } from './brand.ts'
|
||||
|
||||
/** Opaque content-addressed identifier for one immutable attachment object. */
|
||||
export type AttachmentId = Branded<'AttachmentId'>
|
||||
|
||||
/**
|
||||
* Brand a validated storage identifier.
|
||||
* @param value - backend-produced opaque identifier.
|
||||
* @returns the branded identifier.
|
||||
*/
|
||||
export function AttachmentId(value: string): AttachmentId {
|
||||
return value as AttachmentId
|
||||
}
|
||||
export type { AttachmentId } from './brand.ts'
|
||||
|
||||
/** Raster image formats accepted by the version-one attachment path. */
|
||||
export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
|
||||
@@ -56,20 +46,3 @@ export interface StoredImageAttachment {
|
||||
ref: ImageAttachmentRef
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
/** Stable failures suitable for host RPC error mapping. */
|
||||
export class AttachmentError extends Error {
|
||||
/** Stable machine-routing failure code. */
|
||||
readonly code: string
|
||||
|
||||
/**
|
||||
* @param message - human-readable failure description without raw bytes or host paths.
|
||||
* @param code - stable machine-routing code.
|
||||
* @param options - optional chained cause.
|
||||
*/
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'AttachmentError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user