@goodandready/dsh-image-gen 0.10.15 → 0.10.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,6 +84,14 @@
84
84
  * **Subscription Aspect Ratio Mapping**: maps aspect ratios (`16:9`, `3:2`, `9:16`, `2:3`) to appropriate subscription dimensions (`1536x1024` / `1024x1536`) instead of falling back to default square `1024x1024`.
85
85
 
86
86
 
87
+ ### 🚀 What's New in v0.10.16
88
+ * **Safe Attachment Service Fallback (#214)**: Added graceful fallback handling in `saveAttachmentSafe` across all 8 visual tools (`generate_image`, variations, `remove_background`, `upscale_image`, `blend_images`, etc.). When running in headless CLI mode or when the attachment store is unavailable, outputs are safely written to disk with full file paths and data references rather than crashing tool execution.
89
+ * **HTTP Image Endpoint Strict Validation**: Hardened `/dsh-image-gen/image` route against malformed or NaN parameters (`b`, `w`, `h`), cleanly responding with HTTP 400 or 404 without leaking internal system traces.
90
+ * **ComfyUI Node Error Extraction**: Integrated `extractComfyNodeErrors` to parse node execution errors from `/history/{pid}` status responses, immediately reporting root-cause node diagnostics rather than timing out.
91
+ * **Custom Provider Error Normalization**: Standardized gateway error extracts through `formatErrorMessage`, eliminating raw JSON or `[object Object]` artifacts on 4xx/5xx API responses.
92
+ * **Cache & History Consistency**: Ensured content-addressed disk cache hits synchronize seamlessly with generation history and sidecar metadata.
93
+ * **Test Suite Expansion**: Added `test/batch4-hardening.test.mjs`, expanding test coverage to **128 automated unit tests (100% pass)**.
94
+
87
95
  ### 🚀 What's New in v0.10.15
88
96
  * **Loop Guard Zero-Limit Bypass (#212)**: Fixed an edge case where setting `loopGuardLimit: 0` (configured to disable protection) enforced a limit of 1 due to `Math.max(1, limit)`. Setting limit to 0 now properly bypasses loop checks.
89
97
  * **Security Hardening (Token Masking)**: Added automatic pattern masking for Replicate API tokens (`r8_...`) and Google Gemini API keys (`AIza...`) in `sanitizeErrorAndLogs`.
package/README.ru.md CHANGED
@@ -130,6 +130,14 @@ graph LR
130
130
  - **100% интернационализация (#140)**: устранены захардкоженные русские строки, добавлены переводы на русский, английский и китайский языки.
131
131
 
132
132
 
133
+ ### 🚀 Что нового в v0.10.16
134
+ * **Безопасный Fallback сервиса вложений (#214)**: Реализована функция `saveAttachmentSafe` во всех 8 визуальных инструментах (`generate_image`, вариации, `remove_background`, `upscale_image`, `blend_images` и др.). При запуске в автономном режиме без сервиса вложений или при сбое хранилища инструмент гарантированно сохраняет файл на диск и отдает пути без падения.
135
+ * **Строгая валидация HTTP-эндпоинта картинок**: В обработчике `/dsh-image-gen/image` усилена проверка query-параметров (`b`, `w`, `h`), защищающая от `NaN` и отрицательных чисел, а также исключающая утечку внутренних трассировок при 404/500 ошибках.
136
+ * **Диагностика ошибок нод ComfyUI**: Добавлена функция `extractComfyNodeErrors`, мгновенно извлекающая сообщения об ошибках нод из `/history/{pid}` при сбое генерации вместо бесконечного ожидания таймаута.
137
+ * **Нормализация ошибок Custom/OpenAI API**: Ошибки сторонних шлюзов и API провайдеров единообразно форматируются через `formatErrorMessage`, предотвращая появление `[object Object]` или громоздких дампов JSON.
138
+ * **Согласованность истории и дискового кэша**: Попадания в кэш корректно поддерживают актуальность метаданных sidecar и истории генераций.
139
+ * **Расширение тестов**: Добавлен тестовый набор `test/batch4-hardening.test.mjs`, общее количество тестов выросло до **128 (100% pass)**.
140
+
133
141
  ### 🚀 Что нового в v0.10.15
134
142
  * **Обход Loop Guard при нулевом лимите (#212)**: Исправлена ошибка, когда при значении `loopGuardLimit: 0` (отключение защиты) срабатывал `Math.max(1, 0) = 1` и блокировал вызовы на 2-й итерации. При значении 0 лимит теперь полностью отключается.
135
143
  * **Усиление безопасности токенов**: В `sanitizeErrorAndLogs` добавлено маскирование токенов Replicate (`r8_...`) и ключей Google Gemini (`AIza...`).
package/lib/index.js CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  resolveStylePreset,
48
48
  blendImagesFal,
49
49
  toLosslessJson,
50
+ saveAttachmentSafe,
50
51
  } from './providers.js'
51
52
 
52
53
  import { resolveConversationImage, analyzeImageWithVision, buildDualOutputMarkdown } from './resolve-image.js'
@@ -72,6 +73,9 @@ export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMedi
72
73
  /**
73
74
  * Сохраняет сгенерированный ассет в воркспейс, регистрирует в attachments и возвращает Dual-Output (#150).
74
75
  */
76
+
77
+
78
+
75
79
  export async function saveAndAttachResult(ctx, exec, cfg, {
76
80
  bytes,
77
81
  mediaType,
@@ -89,11 +93,7 @@ export async function saveAndAttachResult(ctx, exec, cfg, {
89
93
  args = {},
90
94
  action = 'generated',
91
95
  }) {
92
- const attachment = await ctx.attachments.saveImage({
93
- data: new Uint8Array(bytes),
94
- mediaType,
95
- name,
96
- })
96
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
97
97
 
98
98
  const sessionCwd = exec?.agent?.session?.header?.cwd
99
99
  const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
@@ -102,12 +102,6 @@ export async function saveAndAttachResult(ctx, exec, cfg, {
102
102
  const filePath = path.join(outDir, name)
103
103
  await writeFile(filePath, bytes)
104
104
 
105
- const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
106
- + '&mt=' + encodeURIComponent(attachment.mediaType)
107
- + '&b=' + encodeURIComponent(String(attachment.bytes))
108
- + '&w=' + encodeURIComponent(String(attachment.width))
109
- + '&h=' + encodeURIComponent(String(attachment.height))
110
-
111
105
  await writeFile(
112
106
  path.join(outDir, `${stem}.json`),
113
107
  JSON.stringify(buildSidecar({
@@ -614,14 +608,26 @@ export function apply(ctx, config) {
614
608
  // The tool result carries all of them, so the card sends them back.
615
609
  // Nothing is gained by forging them — the bytes are still verified
616
610
  // against the sha256 in the id, and a mismatch is simply a 404.
611
+ const rawBytes = Number(query.get('b'))
612
+ const rawW = Number(query.get('w'))
613
+ const rawH = Number(query.get('h'))
614
+ const safeBytes = Number.isFinite(rawBytes) && rawBytes >= 0 ? rawBytes : 0
615
+ const safeW = Number.isFinite(rawW) && rawW >= 0 ? rawW : 0
616
+ const safeH = Number.isFinite(rawH) && rawH >= 0 ? rawH : 0
617
+
617
618
  const ref = {
618
619
  attachmentId: id,
619
620
  mediaType: query.get('mt') || 'image/png',
620
- bytes: Number(query.get('b')) || 0,
621
- width: Number(query.get('w')) || 0,
622
- height: Number(query.get('h')) || 0,
621
+ bytes: safeBytes,
622
+ width: safeW,
623
+ height: safeH,
623
624
  }
624
625
  try {
626
+ if (!ctx.attachments || typeof ctx.attachments.readImage !== 'function') {
627
+ res.writeHead(404, { 'Content-Type': 'application/json' })
628
+ res.end(JSON.stringify({ error: 'attachment service unavailable' }))
629
+ return
630
+ }
625
631
  const stored = await ctx.attachments.readImage(ref)
626
632
  res.writeHead(200, {
627
633
  'Content-Type': stored.ref?.mediaType || 'image/png',
@@ -631,7 +637,7 @@ export function apply(ctx, config) {
631
637
  res.end(Buffer.from(stored.data))
632
638
  } catch (error) {
633
639
  res.writeHead(404, { 'Content-Type': 'application/json' })
634
- res.end(JSON.stringify({ error: String(error && error.message ? error.message : error) }))
640
+ res.end(JSON.stringify({ error: 'image not found or reference mismatch' }))
635
641
  }
636
642
  }
637
643
  })()
@@ -907,11 +913,7 @@ export function apply(ctx, config) {
907
913
  const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
908
914
  const name = `${stem}.${extension}`
909
915
 
910
- const attachment = await ctx.attachments.saveImage({
911
- data: new Uint8Array(bytes),
912
- mediaType,
913
- name,
914
- })
916
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
915
917
 
916
918
  const sessionCwd = exec.agent?.session?.header?.cwd
917
919
  const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
@@ -920,12 +922,6 @@ export function apply(ctx, config) {
920
922
  const filePath = path.join(outDir, name)
921
923
  await writeFile(filePath, bytes)
922
924
 
923
- const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
924
- + '&mt=' + encodeURIComponent(attachment.mediaType)
925
- + '&b=' + encodeURIComponent(String(attachment.bytes))
926
- + '&w=' + encodeURIComponent(String(attachment.width))
927
- + '&h=' + encodeURIComponent(String(attachment.height))
928
-
929
925
  // Sidecar: рядом с картинкой — метаданные генерации. Каталог становится
930
926
  // самодокументируемым (галерея/повтор/диагностика читают их без БД).
931
927
  await writeFile(
@@ -1055,11 +1051,7 @@ export function apply(ctx, config) {
1055
1051
  const extension = diskHit.mediaType === 'image/jpeg' ? 'jpg' : diskHit.mediaType === 'image/webp' ? 'webp' : 'png'
1056
1052
  const stem = `${slugify(args.output_name || promptVal)}-${Date.now().toString(36)}-${seedVal}`
1057
1053
  const name = `${stem}.${extension}`
1058
- const attachment = await ctx.attachments.saveImage({
1059
- data: new Uint8Array(diskHit.bytes),
1060
- mediaType: diskHit.mediaType,
1061
- name,
1062
- })
1054
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: diskHit.bytes, mediaType: diskHit.mediaType, name })
1063
1055
  const sessionCwd = exec.agent?.session?.header?.cwd
1064
1056
  const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
1065
1057
  const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
@@ -1067,12 +1059,6 @@ export function apply(ctx, config) {
1067
1059
  const filePath = path.join(outDir, name)
1068
1060
  await writeFile(filePath, diskHit.bytes)
1069
1061
 
1070
- const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
1071
- + '&mt=' + encodeURIComponent(attachment.mediaType)
1072
- + '&b=' + encodeURIComponent(String(attachment.bytes))
1073
- + '&w=' + encodeURIComponent(String(attachment.width))
1074
- + '&h=' + encodeURIComponent(String(attachment.height))
1075
-
1076
1062
  return {
1077
1063
  path: filePath,
1078
1064
  url: localUrl,
@@ -1211,17 +1197,12 @@ export function apply(ctx, config) {
1211
1197
  )
1212
1198
  const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
1213
1199
  const name = `${stem}.png`
1214
- const attachment = await ctx.attachments.saveImage({
1215
- data: new Uint8Array(res.bytes),
1216
- mediaType: 'image/png',
1217
- name,
1218
- })
1200
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
1219
1201
  const sessionCwd = exec.agent?.session?.header?.cwd
1220
1202
  const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
1221
1203
  await mkdir(outDir, { recursive: true })
1222
1204
  const filePath = path.join(outDir, name)
1223
1205
  await writeFile(filePath, res.bytes)
1224
- const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
1225
1206
  const summary = buildDualOutputMarkdown({
1226
1207
  action: 'background removed',
1227
1208
  filePath,
@@ -1284,17 +1265,12 @@ export function apply(ctx, config) {
1284
1265
  )
1285
1266
  const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
1286
1267
  const name = `${stem}.png`
1287
- const attachment = await ctx.attachments.saveImage({
1288
- data: new Uint8Array(res.bytes),
1289
- mediaType: 'image/png',
1290
- name,
1291
- })
1268
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
1292
1269
  const sessionCwd = exec.agent?.session?.header?.cwd
1293
1270
  const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
1294
1271
  await mkdir(outDir, { recursive: true })
1295
1272
  const filePath = path.join(outDir, name)
1296
1273
  await writeFile(filePath, res.bytes)
1297
- const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
1298
1274
  const summary = buildDualOutputMarkdown({
1299
1275
  action: `upscaled (${args.scale || 2}x)`,
1300
1276
  filePath,
@@ -1425,17 +1401,12 @@ export function apply(ctx, config) {
1425
1401
  )
1426
1402
  const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
1427
1403
  const name = `${stem}.png`
1428
- const attachment = await ctx.attachments.saveImage({
1429
- data: new Uint8Array(res.bytes),
1430
- mediaType: 'image/png',
1431
- name,
1432
- })
1404
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
1433
1405
  const sessionCwd = exec.agent?.session?.header?.cwd
1434
1406
  const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
1435
1407
  await mkdir(outDir, { recursive: true })
1436
1408
  const filePath = path.join(outDir, name)
1437
1409
  await writeFile(filePath, res.bytes)
1438
- const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
1439
1410
  const summary = buildDualOutputMarkdown({
1440
1411
  action: 'blended composition',
1441
1412
  filePath,
package/lib/providers.js CHANGED
@@ -461,6 +461,60 @@ export async function pixelDiff(a, b) {
461
461
  return { diffRatio: diff / a.length }
462
462
  }
463
463
 
464
+ /** Извлечение детальных сообщений об ошибках нод ComfyUI из ответа /history/{pid}. */
465
+
466
+ /** Безопасное сохранение вложения с fallback'ом при отсутствии ctx.attachments */
467
+ export async function saveAttachmentSafe(ctx, { bytes, mediaType, name }) {
468
+ if (ctx && ctx.attachments && typeof ctx.attachments.saveImage === 'function') {
469
+ try {
470
+ const att = await ctx.attachments.saveImage({
471
+ data: new Uint8Array(bytes),
472
+ mediaType,
473
+ name,
474
+ })
475
+ if (att && att.attachmentId) {
476
+ const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(att.attachmentId)
477
+ + '&mt=' + encodeURIComponent(att.mediaType || mediaType)
478
+ + '&b=' + encodeURIComponent(String(att.bytes || bytes.length))
479
+ + '&w=' + encodeURIComponent(String(att.width || 0))
480
+ + '&h=' + encodeURIComponent(String(att.height || 0))
481
+ return { attachment: att, localUrl }
482
+ }
483
+ } catch (err) {
484
+ // Fallback below if attachment service fails
485
+ }
486
+ }
487
+ return {
488
+ attachment: {
489
+ attachmentId: '',
490
+ mediaType,
491
+ bytes: bytes.length,
492
+ width: 0,
493
+ height: 0,
494
+ name,
495
+ },
496
+ localUrl: '',
497
+ }
498
+ }
499
+
500
+ export function extractComfyNodeErrors(entry) {
501
+ if (!entry || typeof entry !== 'object') return ''
502
+ const status = entry.status
503
+ if (status && status.status_str === 'error') {
504
+ const msgs = status.messages || []
505
+ const errList = []
506
+ for (const m of msgs) {
507
+ if (Array.isArray(m) && m[0] === 'execution_error') {
508
+ const d = m[1] || {}
509
+ errList.push(`node ${d.node_id || 'unknown'} (${d.node_type || ''}): ${d.exception_message || d.exception_type || 'execution failed'}`)
510
+ }
511
+ }
512
+ if (errList.length) return errList.join('; ')
513
+ return status.status_str || 'execution error'
514
+ }
515
+ return ''
516
+ }
517
+
464
518
  export function makeProviders(deps, job) {
465
519
  const { fetchImpl, resolveKey, cfg } = deps
466
520
  const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
@@ -553,7 +607,7 @@ export function makeProviders(deps, job) {
553
607
  throw new Error(`Custom image API has no provisioned channel for "${cfg.customModel}" (HTTP 503); check gateway channel and routing configuration`)
554
608
  }
555
609
  }
556
- const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
610
+ const detail = formatErrorMessage(data?.error || data)
557
611
  throw new Error(`Image API failed (HTTP ${res.status}): ${detail}`)
558
612
  }
559
613
  const item = data?.data?.[0]
@@ -727,6 +781,12 @@ export function makeProviders(deps, job) {
727
781
  if (!hist.ok) continue
728
782
  const histData = await hist.json().catch(() => ({}))
729
783
  const entry = histData[pid]
784
+ if (entry) {
785
+ const comfyErr = extractComfyNodeErrors(entry)
786
+ if (comfyErr) {
787
+ throw new Error(`Local ComfyUI execution failed: ${comfyErr}`)
788
+ }
789
+ }
730
790
  if (entry && entry.outputs) {
731
791
  const outputs = entry.outputs
732
792
  const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.10.15",
3
+ "version": "0.10.16",
4
4
  "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
5
5
  "keywords": [
6
6
  "deepseek-harness",