@goodandready/dsh-image-gen 0.11.33 → 0.11.35

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.
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path'
2
2
  import { mkdir, writeFile } from 'node:fs/promises'
3
3
  import { saveAttachmentSafe, buildSidecar, toLosslessJson, ensureImageFormat } from './providers.js'
4
+ import { resolveInside } from './security.js'
4
5
  import { recordSpend } from './cost-meter.js'
5
6
  import { buildDualOutputMarkdown } from './resolve-image.js'
6
7
 
@@ -29,7 +30,7 @@ export async function saveAndAttachResult(ctx, exec, cfg, {
29
30
 
30
31
  const sessionCwd = exec?.agent?.session?.header?.cwd
31
32
  const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
32
- const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
33
+ const outDir = resolveInside(sessionCwd || process.cwd(), targetDir)
33
34
  await mkdir(outDir, { recursive: true })
34
35
  const filePath = path.join(outDir, name)
35
36
  await writeFile(filePath, bytes)
@@ -1,7 +1,7 @@
1
1
  // lib/config-schema.js — Zod configuration schema and normalization helpers (#317)
2
2
 
3
3
  import z from '@deepseek-ai/schemastery'
4
- import { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS } from './providers.js'
4
+ import { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS } from './provider-utils.js'
5
5
 
6
6
  // Polyfill for .volatile() schema annotation if runtime schemastery lacks it (#295)
7
7
  if (typeof z.prototype?.volatile !== 'function') {
@@ -343,15 +343,25 @@ export function isVolatileRef(value) {
343
343
  return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
344
344
  }
345
345
 
346
- export function plainConfig(cfg) {
347
- if (isVolatileRef(cfg)) return plainConfig(cfg.get())
348
- if (!cfg || typeof cfg !== 'object') return cfg
349
- const out = {}
350
- for (const key of Object.keys(cfg)) {
351
- const value = cfg[key]
352
- out[key] = isVolatileRef(value) ? value.get() : value
346
+ /**
347
+ * Deeply unwrap Cosmokit/Schemastery Volatile boxes into plain primitives/objects/arrays (#282, #417).
348
+ */
349
+ export function plainConfig(value) {
350
+ if (value === null || value === undefined) return value
351
+ if (typeof value === 'function') return plainConfig(value())
352
+ if (isVolatileRef(value)) return plainConfig(value.get())
353
+ if (Array.isArray(value)) return value.map(plainConfig)
354
+ if (typeof value === 'object') {
355
+ if (Buffer.isBuffer(value) || value instanceof Date || value instanceof RegExp || value instanceof URL) {
356
+ return value
357
+ }
358
+ const out = {}
359
+ for (const [k, v] of Object.entries(value)) {
360
+ out[k] = plainConfig(v)
361
+ }
362
+ return out
353
363
  }
354
- return out
364
+ return value
355
365
  }
356
366
 
357
367
  export function volatileConfig(cfg) {
@@ -1,3 +1,4 @@
1
+ import { resolveInside } from './security.js'
1
2
  import sharp from 'sharp'
2
3
  // @ts-check
3
4
  import path from 'node:path'
@@ -712,20 +713,20 @@ export async function generatePwaIconSuite({
712
713
  // 6. Write files to outDir if provided
713
714
  const exportedFiles = []
714
715
  if (outDir) {
715
- const fullIconsDir = path.resolve(outDir, iconsDir)
716
+ const fullIconsDir = resolveInside(outDir, iconsDir || 'icons')
716
717
  await fs.mkdir(fullIconsDir, { recursive: true })
717
718
 
718
719
  for (const item of generatedIcons) {
719
- const filePath = path.resolve(outDir, item.filename)
720
+ const filePath = resolveInside(outDir, item.filename)
720
721
  await fs.writeFile(filePath, item.bytes)
721
722
  exportedFiles.push(filePath)
722
723
  }
723
724
 
724
- const icoPath = path.resolve(outDir, faviconIcoFilename)
725
+ const icoPath = resolveInside(outDir, faviconIcoFilename)
725
726
  await fs.writeFile(icoPath, faviconIcoBuffer)
726
727
  exportedFiles.push(icoPath)
727
728
 
728
- const manifestPath = path.resolve(outDir, 'manifest.json')
729
+ const manifestPath = resolveInside(outDir, 'manifest.json')
729
730
  await fs.writeFile(manifestPath, manifestJson)
730
731
  exportedFiles.push(manifestPath)
731
732
  }
@@ -20,6 +20,7 @@ import { enhancePrompt } from './prompt-enhancer.js'
20
20
  import { buildFaceConsistencyPrompt, getSessionAnchor, applyAnchorPromptHints } from './anchor-helpers.js'
21
21
  import { sanitizeNegativePrompt, supportsNegativePrompt } from './negative-sanitizer.js'
22
22
  import { recordSpend } from './cost-meter.js'
23
+ import { resolveInside } from './security.js'
23
24
  import { setCachedGeneration } from './generation-cache.js'
24
25
  import {
25
26
  readHistory,
@@ -183,7 +184,7 @@ export async function saveGeneratedImageArtifact({
183
184
 
184
185
  const sessionCwd = exec?.agent?.session?.header?.cwd
185
186
  const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
186
- const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
187
+ const outDir = resolveInside(sessionCwd || process.cwd(), targetDir)
187
188
  await mkdir(outDir, { recursive: true })
188
189
  const filePath = path.join(outDir, name)
189
190
  await writeFile(filePath, bytes)
package/lib/index.js CHANGED
@@ -367,13 +367,14 @@ export function apply(ctx, rawConfig) {
367
367
  try {
368
368
  const u = new URL(req.url, 'http://localhost')
369
369
  const qProvider = u.searchParams.get('provider')
370
+ const effectiveCfg = live()
370
371
  const provider = (typeof qProvider === 'string' && qProvider.trim())
371
- || (typeof config?.provider === 'string' && config.provider.trim())
372
+ || (typeof effectiveCfg?.provider === 'string' && effectiveCfg.provider.trim())
372
373
  || 'fal'
373
374
  const deps = {
374
375
  fetchImpl: fetch,
375
376
  resolveKey: (ref) => resolveApiKey(ctx, ref),
376
- cfg: (typeof getConfig === 'function') ? getConfig() : config,
377
+ cfg: effectiveCfg,
377
378
  ctx,
378
379
  }
379
380
  const result = await testProviderConnection(deps, provider)
package/lib/providers.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import sharp from 'sharp'
2
+ import { plainConfig } from './config-schema.js'
2
3
  import { CURATED_STYLES, normalizeStyleKey } from './prompt-polisher.js'
3
4
  /**
4
5
  * Returns candidate API key names considering known aliases (e.g. FAL_API_KEY <-> FAL_KEY).
@@ -221,16 +222,17 @@ export async function saveAttachmentSafe(ctx, { bytes, mediaType, name }) {
221
222
 
222
223
 
223
224
  export function makeProviders(deps, job) {
224
- const subscription = createSubscriptionGenerator(deps, job)
225
+ const safeDeps = deps?.cfg ? { ...deps, cfg: plainConfig(deps.cfg) } : deps
226
+ const subscription = createSubscriptionGenerator(safeDeps, job)
225
227
  return {
226
- fal: createFalGenerator(deps, job),
227
- custom: createCustomGenerator(deps, job),
228
+ fal: createFalGenerator(safeDeps, job),
229
+ custom: createCustomGenerator(safeDeps, job),
228
230
  codex: subscription('codex'),
229
231
  grok: subscription('grok'),
230
- local: createLocalGenerator(deps, job),
231
- seedream: createSeedreamGenerator(deps, job),
232
- gemini: createGeminiGenerator(deps, job),
233
- replicate: createReplicateGenerator(deps, job),
232
+ local: createLocalGenerator(safeDeps, job),
233
+ seedream: createSeedreamGenerator(safeDeps, job),
234
+ gemini: createGeminiGenerator(safeDeps, job),
235
+ replicate: createReplicateGenerator(safeDeps, job),
234
236
  }
235
237
  }
236
238
 
@@ -279,7 +281,8 @@ export function embedPngMetadata(pngBytes, metadata = {}) {
279
281
  return Buffer.concat([buf.subarray(0, insertPos), chunk, buf.subarray(insertPos)])
280
282
  }
281
283
 
282
- export async function removeBackgroundFal({ fetchImpl, resolveKey, cfg }, { imageBytes, mediaType = 'image/png', model = 'fal-ai/birefnet', signal }) {
284
+ export async function removeBackgroundFal({ fetchImpl, resolveKey, cfg: rawCfg }, { imageBytes, mediaType = 'image/png', model = 'fal-ai/birefnet', signal }) {
285
+ const cfg = plainConfig(rawCfg)
283
286
  const key = await resolveKey(cfg.apiKeyEnv)
284
287
  const base64 = Buffer.isBuffer(imageBytes) ? imageBytes.toString('base64') : Buffer.from(imageBytes).toString('base64')
285
288
  const dataUrl = `data:${mediaType};base64,${base64}`
@@ -297,7 +300,8 @@ export async function removeBackgroundFal({ fetchImpl, resolveKey, cfg }, { imag
297
300
  return { bytes, mediaType: 'image/png', width: img.width || 0, height: img.height || 0, sourceUrl: img.url }
298
301
  }
299
302
 
300
- export async function upscaleImageFal({ fetchImpl, resolveKey, cfg }, { imageBytes, mediaType = 'image/png', scale = 2, creativity, prompt, model = 'fal-ai/clarity-upscaler', signal }) {
303
+ export async function upscaleImageFal({ fetchImpl, resolveKey, cfg: rawCfg }, { imageBytes, mediaType = 'image/png', scale = 2, creativity, prompt, model = 'fal-ai/clarity-upscaler', signal }) {
304
+ const cfg = plainConfig(rawCfg)
301
305
  const key = await resolveKey(cfg.apiKeyEnv)
302
306
  const base64 = Buffer.isBuffer(imageBytes) ? imageBytes.toString('base64') : Buffer.from(imageBytes).toString('base64')
303
307
  const dataUrl = `data:${mediaType};base64,${base64}`
@@ -579,8 +583,9 @@ export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, w
579
583
  /** Direct image editing (inpainting / img2img) via selected provider. */
580
584
  /** @internal — direct provider call path for integration tests */
581
585
  export async function editImageDirect(deps, job) {
582
- const providers = makeProviders(deps, job)
583
- const providerKey = (typeof job?.provider === 'string' && job.provider.trim()) || (typeof deps?.cfg?.provider === 'string' && deps.cfg.provider.trim()) || (typeof deps?.cfg?.defaultProvider === 'string' && deps.cfg.defaultProvider.trim()) || 'fal'
586
+ const safeDeps = deps?.cfg ? { ...deps, cfg: plainConfig(deps.cfg) } : deps
587
+ const providers = makeProviders(safeDeps, job)
588
+ const providerKey = (typeof job?.provider === 'string' && job.provider.trim()) || (typeof safeDeps?.cfg?.provider === 'string' && safeDeps.cfg.provider.trim()) || (typeof safeDeps?.cfg?.defaultProvider === 'string' && safeDeps.cfg.defaultProvider.trim()) || 'fal'
584
589
  const fn = providers[providerKey] || providers.fal || providers.custom
585
590
  if (!fn) throw new Error(`Provider "${providerKey}" does not support image editing`)
586
591
  return fn(job.seed, job.prompt)
@@ -589,11 +594,12 @@ export async function editImageDirect(deps, job) {
589
594
  /** Direct image variation via selected provider. */
590
595
  /** @internal — direct provider call path for integration tests */
591
596
  export async function varyImageDirect(deps, job) {
592
- const providers = makeProviders(deps, {
597
+ const safeDeps = deps?.cfg ? { ...deps, cfg: plainConfig(deps.cfg) } : deps
598
+ const providers = makeProviders(safeDeps, {
593
599
  ...job,
594
600
  strength: job.variationStrength ?? job.strength ?? 0.35,
595
601
  })
596
- const providerKey = (typeof job?.provider === 'string' && job.provider.trim()) || (typeof deps?.cfg?.provider === 'string' && deps.cfg.provider.trim()) || (typeof deps?.cfg?.defaultProvider === 'string' && deps.cfg.defaultProvider.trim()) || 'fal'
602
+ const providerKey = (typeof job?.provider === 'string' && job.provider.trim()) || (typeof safeDeps?.cfg?.provider === 'string' && safeDeps.cfg.provider.trim()) || (typeof safeDeps?.cfg?.defaultProvider === 'string' && safeDeps.cfg.defaultProvider.trim()) || 'fal'
597
603
  const fn = providers[providerKey] || providers.fal || providers.custom
598
604
  if (!fn) throw new Error(`Provider "${providerKey}" does not support image variations`)
599
605
  return fn(job.seed, job.prompt)
@@ -628,7 +634,8 @@ export function toLosslessJson(val) {
628
634
  * Returns { ok: boolean, latencyMs: number, message: string }.
629
635
  */
630
636
  export async function testProviderConnection(deps, providerId) {
631
- const { fetchImpl = fetch, resolveKey, cfg } = deps || {}
637
+ const { fetchImpl = fetch, resolveKey } = deps || {}
638
+ const cfg = plainConfig(deps?.cfg)
632
639
  const start = Date.now()
633
640
  const rawProvider = (typeof providerId === 'string' && providerId.trim())
634
641
  || (typeof cfg?.provider === 'string' && cfg.provider.trim())
package/lib/security.js CHANGED
@@ -1,8 +1,5 @@
1
- // lib/security.js
2
- // Secure credential handling, 0600 file permissions, and token masking (#169)
3
- // Address validation and trusted request guards against DNS rebinding & cross-site leaks (Refs: GitHub #1, #280, #276, #277)
4
-
5
- import { chmodSync, existsSync } from 'node:fs'
1
+ import path from 'node:path'
2
+ import { chmodSync, existsSync, realpathSync } from 'node:fs'
6
3
 
7
4
  /**
8
5
  * Masks sensitive API keys, leaving only the prefix/suffix.
@@ -233,4 +230,67 @@ export function isTrustedLocalRequest(req) {
233
230
  }
234
231
 
235
232
  return true
236
- }
233
+ }
234
+
235
+ /**
236
+ * Resolves candidatePath strictly inside baseDir.
237
+ * If candidatePath is absolute or contains '..', it must resolve inside baseDir.
238
+ * Both lexical containment check and realpath check (for baseDir and existing targets/ancestors)
239
+ * are performed to prevent symlink and lexical directory traversal attacks (#409, #410, #411, #412, #420).
240
+ *
241
+ * @param {string} baseDir Base directory (e.g. sessionCwd or workspace root)
242
+ * @param {string} candidatePath Relative or sub-path to resolve
243
+ * @returns {string} Absolute resolved path guaranteed to be contained within baseDir
244
+ * @throws {Error} if candidatePath escapes baseDir
245
+ */
246
+ export function resolveInside(baseDir, candidatePath) {
247
+ const root = path.resolve(baseDir || '.')
248
+ const sub = candidatePath ? String(candidatePath).trim() : '.'
249
+ const resolved = path.resolve(root, sub)
250
+ const rel = path.relative(root, resolved)
251
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
252
+ throw new Error(`Path traversal denied: "${candidatePath}" escapes base directory "${root}"`)
253
+ }
254
+
255
+ let realRoot = root
256
+ try {
257
+ realRoot = realpathSync(root)
258
+ } catch (_err) {
259
+ // If baseDir does not exist yet on disk, lexical containment check above is sufficient
260
+ return resolved
261
+ }
262
+
263
+ try {
264
+ const realTarget = realpathSync(resolved)
265
+ const relReal = path.relative(realRoot, realTarget)
266
+ if (relReal.startsWith('..') || path.isAbsolute(relReal)) {
267
+ throw new Error(`Path traversal denied: "${candidatePath}" resolves via symlink outside "${realRoot}"`)
268
+ }
269
+ } catch (err) {
270
+ if (err?.code === 'ENOENT') {
271
+ let curr = path.dirname(resolved)
272
+ while (curr === root || curr.startsWith(root + path.sep)) {
273
+ try {
274
+ const realCurr = realpathSync(curr)
275
+ const relCurr = path.relative(realRoot, realCurr)
276
+ if (relCurr.startsWith('..') || path.isAbsolute(relCurr)) {
277
+ throw new Error(`Path traversal denied: ancestor in "${candidatePath}" resolves via symlink outside "${realRoot}"`)
278
+ }
279
+ break
280
+ } catch (ancestorErr) {
281
+ if (ancestorErr?.code === 'ENOENT') {
282
+ const parent = path.dirname(curr)
283
+ if (parent === curr) break
284
+ curr = parent
285
+ continue
286
+ }
287
+ throw ancestorErr
288
+ }
289
+ }
290
+ } else {
291
+ throw err
292
+ }
293
+ }
294
+
295
+ return resolved
296
+ }
@@ -4,7 +4,7 @@ import { renderToolOutput } from "../attachment-helper.js"
4
4
  import path from 'node:path'
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools'
6
6
  import { toLosslessJson } from '../providers.js'
7
- import { sanitizeErrorAndLogs } from '../security.js'
7
+ import { sanitizeErrorAndLogs, resolveInside } from '../security.js'
8
8
  import {
9
9
  extractDesignTokens,
10
10
  generateCssGradient,
@@ -219,7 +219,7 @@ export function registerFrontendTools(ctx, deps) {
219
219
  }
220
220
  }
221
221
  const sessionCwd = exec?.agent?.session?.header?.cwd || process.cwd()
222
- const outDir = path.resolve(sessionCwd, args.out_dir || '.')
222
+ const outDir = resolveInside(sessionCwd, args.out_dir || args.output_dir || '.')
223
223
 
224
224
  const res = await generatePwaIconSuite({
225
225
  name: args.name || 'App',
@@ -1,4 +1,5 @@
1
1
  import { buildPresentationMeta } from '../attachment-helper.js'
2
+ import { resolveInside } from '../security.js'
2
3
  import { registerGenerationPackTool } from './generation-pack.js'
3
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
5
  import path from 'node:path'
@@ -496,7 +497,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
496
497
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: diskHit.bytes, mediaType: diskHit.mediaType, name })
497
498
  const sessionCwd = exec?.agent?.session?.header?.cwd
498
499
  const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
499
- const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
500
+ const outDir = resolveInside(sessionCwd || process.cwd(), targetDir)
500
501
  await mkdir(outDir, { recursive: true })
501
502
  const filePath = path.join(outDir, name)
502
503
  await writeFile(filePath, diskHit.bytes)
@@ -10,7 +10,7 @@ import {
10
10
  toLosslessJson,
11
11
  saveAttachmentSafe,
12
12
  } from '../providers.js'
13
- import { sanitizeErrorAndLogs } from '../security.js'
13
+ import { sanitizeErrorAndLogs, resolveInside } from '../security.js'
14
14
  import { resolveConversationImage } from '../resolve-image.js'
15
15
 
16
16
  export function registerProcessingAdvancedTools(ctx, deps) {
@@ -408,7 +408,7 @@ export function registerProcessingAdvancedTools(ctx, deps) {
408
408
  async execute(args, exec) {
409
409
  try {
410
410
  const sessionCwd = exec?.agent?.session?.header?.cwd || process.cwd()
411
- const targetDir = path.resolve(sessionCwd, args.output_dir || './assets/branding')
411
+ const targetDir = resolveInside(sessionCwd, args.output_dir || './assets/branding')
412
412
  await mkdir(targetDir, { recursive: true })
413
413
 
414
414
  const projectName = args.project_name || 'Project Brand'
@@ -13,7 +13,7 @@ import {
13
13
  PROVIDER_KEYS,
14
14
  } from '../providers.js'
15
15
  import { trackAndAssertLoopGuard } from '../loop-guard.js'
16
- import { sanitizeErrorAndLogs } from '../security.js'
16
+ import { sanitizeErrorAndLogs, resolveInside } from '../security.js'
17
17
  import { buildDualOutputMarkdown } from '../resolve-image.js'
18
18
  import {
19
19
  buildResponsivePrompt,
@@ -119,7 +119,7 @@ export function registerResponsiveTools(ctx, deps) {
119
119
  const paletteColors = Array.isArray(args.palette_colors) ? args.palette_colors : undefined
120
120
  const sessionCwd = exec?.agent?.session?.header?.cwd
121
121
  const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
122
- const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
122
+ const outDir = resolveInside(sessionCwd || process.cwd(), targetDir)
123
123
  await mkdir(outDir, { recursive: true })
124
124
 
125
125
  const pdeps = {
package/lib/vault.js CHANGED
@@ -1,3 +1,5 @@
1
+ import path from 'node:path'
2
+ import os from 'node:os'
1
3
  import { existsSync, unlinkSync } from 'node:fs'
2
4
  import { readHistory, writeHistory, withHistoryLock } from './history.js'
3
5
  import { isTrustedLocalRequest } from './security.js'
@@ -131,7 +133,26 @@ export async function readBoundedRequestBody(req, maxBytes = MAX_VAULT_BODY_BYTE
131
133
  return Buffer.concat(chunks)
132
134
  }
133
135
 
134
- export async function deleteVaultEntry(id) {
136
+
137
+ export function isPathContainedInRoots(targetPath, allowedRoots = []) {
138
+ if (!targetPath || typeof targetPath !== 'string') return false
139
+ const resolvedTarget = path.resolve(targetPath)
140
+ for (const root of allowedRoots) {
141
+ if (!root) continue
142
+ try {
143
+ const resolvedRoot = path.resolve(root)
144
+ const rel = path.relative(resolvedRoot, resolvedTarget)
145
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
146
+ return true
147
+ }
148
+ } catch (_err) {
149
+ /* ignore invalid root */
150
+ }
151
+ }
152
+ return false
153
+ }
154
+
155
+ export async function deleteVaultEntry(id, options = {}) {
135
156
  if (!id) return { success: false, error: 'Missing entry id' }
136
157
  return await withHistoryLock(async () => {
137
158
  const entries = await readHistory()
@@ -140,8 +161,17 @@ export async function deleteVaultEntry(id) {
140
161
  return { success: false, error: 'Vault entry not found' }
141
162
  }
142
163
 
164
+ const allowedRoots = options.allowedRoots || [
165
+ process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
166
+ process.cwd(),
167
+ os.tmpdir(),
168
+ ]
143
169
  const [entry] = entries.splice(idx, 1)
144
170
  if (entry && entry.path) {
171
+ if (!isPathContainedInRoots(entry.path, allowedRoots)) {
172
+ await writeHistory(entries)
173
+ return { success: false, id, error: `Refusing to delete file outside allowed roots: ${entry.path}` }
174
+ }
145
175
  try {
146
176
  unlinkSync(entry.path)
147
177
  } catch (_err) { /* ignore missing file */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.33",
3
+ "version": "0.11.35",
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",