@lievclouds/dsh-cortex 0.4.2 → 0.4.3

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.

Potentially problematic release.


This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.

package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lievclouds/dsh-cortex",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Cortex workbench for DeepSeek Harness: the original Cortex UI (vulnerabilities, assets, internal operations, Memory Nebula, snapshots, retest) as a native DSH panel, plus 12 native agent tools so DSH conversations use the local Cortex SQLite without MCP.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -112,8 +112,11 @@ export function createCortexApi(): CortexApi {
112
112
  patch(`/vulns/${encodeURIComponent(input.id)}/status`, { status: input.status }),
113
113
  deleteVulnerability: (id) => del(`/vulns/${encodeURIComponent(id)}`),
114
114
  deleteVulnerabilities: (ids) => post('/vulns/delete-many', { ids }),
115
- exportVulnerabilities: async (ids) => {
116
- const response = await post('/vulns/export', { ids })
115
+ exportVulnerabilities: async (ids, outputDir) => {
116
+ const response = await post('/vulns/export', {
117
+ ids,
118
+ ...(outputDir && outputDir.trim() !== '' ? { outputDir: outputDir.trim() } : {}),
119
+ })
117
120
  const files = Array.isArray(response?.files) ? response.files : []
118
121
  const savedPaths = Array.isArray(response?.savedPaths) ? response.savedPaths : []
119
122
  return {
@@ -0,0 +1,120 @@
1
+ import * as DialogPrimitive from '@radix-ui/react-dialog'
2
+ import { Download, FolderOpen, Loader2 } from 'lucide-react'
3
+ import { useEffect, useState } from 'react'
4
+ import { Button } from '@renderer/components/ui/button'
5
+ import { useI18n } from '@renderer/i18n'
6
+ import { getPortalContainer } from '../../../dsh/portal'
7
+
8
+ const STORAGE_KEY = 'cortex.export.dir'
9
+
10
+ function isAbsoluteLike(value: string): boolean {
11
+ const trimmed = value.trim()
12
+ if (trimmed.startsWith('~')) return true
13
+ if (/^[A-Za-z]:[\\/]/.test(trimmed)) return true
14
+ if (trimmed.startsWith('\\\\') || trimmed.startsWith('//')) return true
15
+ return trimmed.startsWith('/')
16
+ }
17
+
18
+ interface ExportReportDialogProps {
19
+ open: boolean
20
+ /** Number of reports being exported, for the title/confirm label. */
21
+ count: number
22
+ pending: boolean
23
+ onOpenChange: (open: boolean) => void
24
+ onConfirm: (outputDir: string | undefined) => void
25
+ }
26
+
27
+ /**
28
+ * Export-path picker. The DSH WebView cannot open a native directory picker, so
29
+ * the user types an absolute directory (or leaves it empty for the default
30
+ * `~/.cortex/exports/<timestamp>/`); the Host writes the Markdown there.
31
+ */
32
+ export function ExportReportDialog({
33
+ open,
34
+ count,
35
+ pending,
36
+ onOpenChange,
37
+ onConfirm
38
+ }: ExportReportDialogProps) {
39
+ const { t } = useI18n()
40
+ const [directory, setDirectory] = useState('')
41
+ const [invalid, setInvalid] = useState(false)
42
+
43
+ useEffect(() => {
44
+ if (!open) return
45
+ const stored = window.localStorage.getItem(STORAGE_KEY) ?? ''
46
+ setDirectory(stored)
47
+ setInvalid(false)
48
+ }, [open])
49
+
50
+ const confirm = (): void => {
51
+ const trimmed = directory.trim()
52
+ if (trimmed !== '' && !isAbsoluteLike(trimmed)) {
53
+ setInvalid(true)
54
+ return
55
+ }
56
+ if (trimmed !== '') window.localStorage.setItem(STORAGE_KEY, trimmed)
57
+ else window.localStorage.removeItem(STORAGE_KEY)
58
+ onConfirm(trimmed === '' ? undefined : trimmed)
59
+ }
60
+
61
+ return (
62
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange} modal={false}>
63
+ <DialogPrimitive.Portal container={getPortalContainer()}>
64
+ <DialogPrimitive.Overlay className="sheet-overlay fixed inset-0 z-[70] bg-[hsl(224_18%_10%/0.3)]" />
65
+ <DialogPrimitive.Content
66
+ data-testid="export-report-dialog"
67
+ className="fixed left-1/2 top-1/2 z-[70] w-[min(480px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background p-5 shadow-panel focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
68
+ >
69
+ <DialogPrimitive.Title className="text-[16px] font-medium tracking-tight text-foreground">
70
+ {t('vuln.export.title')}
71
+ </DialogPrimitive.Title>
72
+ <DialogPrimitive.Description className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
73
+ {t('vuln.export.description', { count })}
74
+ </DialogPrimitive.Description>
75
+
76
+ <label
77
+ htmlFor="export-output-dir"
78
+ className="mt-4 flex items-center gap-1.5 text-[11.5px] font-medium text-foreground"
79
+ >
80
+ <FolderOpen aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
81
+ {t('vuln.export.dirLabel')}
82
+ </label>
83
+ <input
84
+ id="export-output-dir"
85
+ data-testid="export-output-dir"
86
+ value={directory}
87
+ spellCheck={false}
88
+ disabled={pending}
89
+ placeholder={t('vuln.export.dirPlaceholder')}
90
+ onChange={(event) => {
91
+ setDirectory(event.target.value)
92
+ if (invalid) setInvalid(false)
93
+ }}
94
+ onKeyDown={(event) => {
95
+ if (event.key === 'Enter') confirm()
96
+ }}
97
+ className="mt-1.5 h-9 w-full rounded-md border border-border bg-background px-2.5 font-mono text-[11.5px] text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
98
+ />
99
+ <p className={invalid ? 'mt-1.5 text-[10.5px] text-red-700' : 'mt-1.5 text-[10.5px] text-muted-foreground'}>
100
+ {invalid ? t('vuln.export.dirInvalid') : t('vuln.export.dirHint')}
101
+ </p>
102
+
103
+ <div className="mt-5 flex items-center justify-end gap-2">
104
+ <Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={pending}>
105
+ {t('common.cancel')}
106
+ </Button>
107
+ <Button size="sm" onClick={confirm} disabled={pending} className="gap-1.5">
108
+ {pending ? (
109
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
110
+ ) : (
111
+ <Download aria-hidden="true" className="h-3.5 w-3.5" />
112
+ )}
113
+ {pending ? t('vuln.detail.exporting') : t('vuln.export.confirm')}
114
+ </Button>
115
+ </div>
116
+ </DialogPrimitive.Content>
117
+ </DialogPrimitive.Portal>
118
+ </DialogPrimitive.Root>
119
+ )
120
+ }
@@ -27,6 +27,7 @@ import { EmptyState } from '@renderer/components/common/EmptyState'
27
27
  import { MarkdownText } from '@renderer/components/common/MarkdownText'
28
28
  import { VulnSummary } from './VulnSummary'
29
29
  import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
30
+ import { ExportReportDialog } from './ExportReportDialog'
30
31
  import { apiUnavailableMessage, getApi, toErrorMessage } from '@renderer/lib/api'
31
32
  import { getPrefetchedVulnerability } from '@renderer/lib/vulnPreload'
32
33
  import { formatDateTime, formatVulnType } from '@renderer/lib/format'
@@ -137,6 +138,7 @@ export function VulnDetail({
137
138
  const [confirmOpen, setConfirmOpen] = useState(false)
138
139
  const [deleting, setDeleting] = useState(false)
139
140
  const [exporting, setExporting] = useState(false)
141
+ const [exportOpen, setExportOpen] = useState(false)
140
142
  const [activeTab, setActiveTab] = useState('steps')
141
143
 
142
144
  const { t, lang, statusLabel } = useI18n()
@@ -252,7 +254,7 @@ export function VulnDetail({
252
254
  }
253
255
  }
254
256
 
255
- const handleExport = async () => {
257
+ const handleExport = async (outputDir?: string) => {
256
258
  if (!detail || loading) return
257
259
  const api = getApi()
258
260
  if (!api) {
@@ -261,7 +263,7 @@ export function VulnDetail({
261
263
  }
262
264
  setExporting(true)
263
265
  try {
264
- const result = await api.exportVulnerabilities([detail.id])
266
+ const result = await api.exportVulnerabilities([detail.id], outputDir)
265
267
  if (!result.cancelled) {
266
268
  const savedPath = result.savedPaths?.[0]
267
269
  toast.success(
@@ -273,6 +275,7 @@ export function VulnDetail({
273
275
  toast.error(t('vuln.toast.exportFailed', { error: toErrorMessage(cause, t) }))
274
276
  } finally {
275
277
  setExporting(false)
278
+ setExportOpen(false)
276
279
  }
277
280
  }
278
281
 
@@ -295,7 +298,7 @@ export function VulnDetail({
295
298
  variant="ghost"
296
299
  size="sm"
297
300
  data-testid="export-vuln-md"
298
- onClick={() => void handleExport()}
301
+ onClick={() => setExportOpen(true)}
299
302
  disabled={loading || exporting}
300
303
  className="h-7 gap-1.5 px-2 text-[11px]"
301
304
  >
@@ -620,6 +623,14 @@ export function VulnDetail({
620
623
  onConfirm={() => void handleDelete()}
621
624
  />
622
625
  ) : null}
626
+
627
+ <ExportReportDialog
628
+ open={exportOpen}
629
+ count={1}
630
+ pending={exporting}
631
+ onOpenChange={setExportOpen}
632
+ onConfirm={(outputDir) => void handleExport(outputDir)}
633
+ />
623
634
  </>
624
635
  )
625
636
  }
@@ -92,6 +92,13 @@ export const vulnMessages: MessageBundle = {
92
92
  'vuln.toast.exportedMany': '已导出 {count} 份 Markdown 报告',
93
93
  'vuln.toast.exported': 'Markdown 报告已导出',
94
94
  'vuln.toast.exportedPath': '已保存到:{path}',
95
+ 'vuln.export.title': '导出 Markdown 报告',
96
+ 'vuln.export.description': '共 {count} 份报告。可指定导出目录,留空则使用默认目录。',
97
+ 'vuln.export.dirLabel': '导出目录',
98
+ 'vuln.export.dirPlaceholder': '例如 C:\\Users\\you\\Downloads',
99
+ 'vuln.export.dirHint': '留空:~/.cortex/exports/<时间戳>/;支持绝对路径或 ~ 开头路径。',
100
+ 'vuln.export.dirInvalid': '请输入绝对路径(如 C:\\Reports 或 ~/Reports)',
101
+ 'vuln.export.confirm': '导出',
95
102
  'vuln.toast.exportFailed': '导出失败:{error}',
96
103
  'vuln.toast.deleted': '已删除 {id}',
97
104
  'vuln.toast.bulkDeleted': '已删除 {count} 条漏洞',
@@ -203,6 +210,13 @@ export const vulnMessages: MessageBundle = {
203
210
  'vuln.toast.exportedMany': 'Exported {count} Markdown reports',
204
211
  'vuln.toast.exported': 'Markdown report exported',
205
212
  'vuln.toast.exportedPath': 'Saved to: {path}',
213
+ 'vuln.export.title': 'Export Markdown report',
214
+ 'vuln.export.description': '{count} report(s). Choose an output directory, or leave empty to use the default.',
215
+ 'vuln.export.dirLabel': 'Output directory',
216
+ 'vuln.export.dirPlaceholder': 'e.g. C:\\Users\\you\\Downloads',
217
+ 'vuln.export.dirHint': 'Empty: ~/.cortex/exports/<timestamp>/; absolute or ~-prefixed paths are accepted.',
218
+ 'vuln.export.dirInvalid': 'Enter an absolute path (e.g. C:\\Reports or ~/Reports)',
219
+ 'vuln.export.confirm': 'Export',
206
220
  'vuln.toast.exportFailed': 'Export failed: {error}',
207
221
  'vuln.toast.deleted': 'Deleted {id}',
208
222
  'vuln.toast.bulkDeleted': 'Deleted {count} vulnerabilities',
@@ -261,7 +261,10 @@ export function createHttpApi(): CortexApi {
261
261
  * saves them. Resolves the downloaded file names, or `cancelled` when there
262
262
  * was nothing to export (`files: []`).
263
263
  */
264
- exportVulnerabilities: async (ids: string[]): Promise<ExportReportsResult> => {
264
+ exportVulnerabilities: async (
265
+ ids: string[],
266
+ _outputDir?: string
267
+ ): Promise<ExportReportsResult> => {
265
268
  const response = await request<WebExportResponse>(WEB_API_PATHS.vulnerabilitiesExport, {
266
269
  method: 'POST',
267
270
  body: ids
@@ -15,6 +15,7 @@ import { PaginationBar } from '@renderer/components/vuln/PaginationBar'
15
15
  import { VulnTable } from '@renderer/components/vuln/VulnTable'
16
16
  import { VulnDetail } from '@renderer/components/vuln/VulnDetail'
17
17
  import { BulkDeleteDialog } from '@renderer/components/vuln/BulkDeleteDialog'
18
+ import { ExportReportDialog } from '@renderer/components/vuln/ExportReportDialog'
18
19
  import { InternalPage } from '@renderer/pages/InternalPage'
19
20
  import { Button } from '@renderer/components/ui/button'
20
21
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
@@ -62,6 +63,7 @@ export function DashboardPage({
62
63
  const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
63
64
  const [bulkDeleting, setBulkDeleting] = useState(false)
64
65
  const [exporting, setExporting] = useState(false)
66
+ const [exportOpen, setExportOpen] = useState(false)
65
67
 
66
68
  const hasLoadedList = useRef(false)
67
69
  const searchInputRef = useRef<HTMLInputElement | null>(null)
@@ -302,17 +304,18 @@ export function DashboardPage({
302
304
  [items]
303
305
  )
304
306
 
305
- const handleExportSelected = async () => {
307
+ const handleExportSelected = async (outputDir?: string) => {
306
308
  const ids = [...selectedIds]
307
309
  if (ids.length === 0) return
308
310
  const api = getApi()
309
311
  if (!api) {
310
312
  toast.error(apiUnavailableMessage(t))
313
+ setExportOpen(false)
311
314
  return
312
315
  }
313
316
  setExporting(true)
314
317
  try {
315
- const result = await api.exportVulnerabilities(ids)
318
+ const result = await api.exportVulnerabilities(ids, outputDir)
316
319
  const savedPath = result.savedPaths?.[0]
317
320
  if (!result.cancelled) {
318
321
  const message =
@@ -328,6 +331,7 @@ export function DashboardPage({
328
331
  toast.error(t('vuln.toast.exportFailed', { error: toErrorMessage(cause, t) }))
329
332
  } finally {
330
333
  setExporting(false)
334
+ setExportOpen(false)
331
335
  }
332
336
  }
333
337
 
@@ -443,7 +447,7 @@ export function DashboardPage({
443
447
  <Button
444
448
  variant="outline"
445
449
  size="sm"
446
- onClick={() => void handleExportSelected()}
450
+ onClick={() => setExportOpen(true)}
447
451
  disabled={exporting}
448
452
  className="gap-1.5"
449
453
  >
@@ -526,6 +530,14 @@ export function DashboardPage({
526
530
  onOpenChange={setBulkConfirmOpen}
527
531
  onConfirm={() => void handleBulkDelete()}
528
532
  />
533
+
534
+ <ExportReportDialog
535
+ open={exportOpen}
536
+ count={selectedCount}
537
+ pending={exporting}
538
+ onOpenChange={setExportOpen}
539
+ onConfirm={(outputDir) => void handleExportSelected(outputDir)}
540
+ />
529
541
  </>
530
542
  )
531
543
  }
@@ -158,8 +158,12 @@ export interface CortexApi extends PocApi {
158
158
  updateVulnerabilityStatus(input: UpdateVulnStatusRequest): Promise<Vulnerability>
159
159
  deleteVulnerability(id: string): Promise<DeleteVulnResult>
160
160
  deleteVulnerabilities(ids: string[]): Promise<DeleteVulnsResult>
161
- /** Export one or more vulnerability reports as Markdown (save file / pick directory). */
162
- exportVulnerabilities(ids: string[]): Promise<ExportReportsResult>
161
+ /**
162
+ * Export one or more vulnerability reports as Markdown. `outputDir` is an
163
+ * absolute directory chosen by the user; omitted/empty means the Host default
164
+ * (`~/.cortex/exports/<timestamp>/`).
165
+ */
166
+ exportVulnerabilities(ids: string[], outputDir?: string): Promise<ExportReportsResult>
163
167
  listAssets(filters?: AssetFilters): Promise<AssetListItem[]>
164
168
  getAssetDetail(hostPort: string): Promise<AssetContext | null>
165
169
  getAssetRelatedVulnerabilities(hostPort: string): Promise<VulnerabilityListItem[]>
@@ -36,7 +36,7 @@ import * as dto from '../dto.js'
36
36
  import { buildVulnReport, reportFilename } from '../reports.js'
37
37
  import { mkdirSync, writeFileSync } from 'node:fs'
38
38
  import { homedir } from 'node:os'
39
- import { join } from 'node:path'
39
+ import { isAbsolute, join, resolve } from 'node:path'
40
40
 
41
41
  const SEVERITIES = ['Critical', 'High', 'Medium', 'Low', 'Info']
42
42
  const STATUSES = ['Confirmed', 'Inconclusive', 'Retest_Passed']
@@ -499,6 +499,26 @@ export async function handleDeleteMany({ req, res, holder, bumpRev }) {
499
499
  export async function handleExport({ req, res, holder, bumpRev }) {
500
500
  const body = await readJsonBody(req)
501
501
  const ids = requireIdList(body, 'ids', MAX_EXPORT_IDS)
502
+ /** Optional user-selected output directory (absolute path, `~` allowed). */
503
+ let requestedDir
504
+ if (body.outputDir !== undefined) {
505
+ if (typeof body.outputDir !== 'string') {
506
+ throw badRequest('outputDir must be a string', { field: 'outputDir' })
507
+ }
508
+ const raw = body.outputDir.trim()
509
+ if (raw.length > 1000) {
510
+ throw badRequest('outputDir is too long', { field: 'outputDir', max: 1000 })
511
+ }
512
+ if (raw.length > 0) {
513
+ const expanded = raw.startsWith('~')
514
+ ? join(homedir(), raw.replace(/^~[/\\]?/, ''))
515
+ : raw
516
+ if (!isAbsolute(expanded)) {
517
+ throw badRequest('outputDir must be an absolute path', { field: 'outputDir' })
518
+ }
519
+ requestedDir = resolve(expanded)
520
+ }
521
+ }
502
522
  if (ids.length === 0) {
503
523
  sendJson(res, 200, { cancelled: true, files: [], savedPaths: [] })
504
524
  return
@@ -541,18 +561,25 @@ export async function handleExport({ req, res, holder, bumpRev }) {
541
561
  .replace(/[-:]/g, '')
542
562
  .replace(/\..+$/, '')
543
563
  .replace('T', '-')
544
- const exportDir = join(exportRoot, stamp)
564
+ const exportDir = requestedDir ?? join(exportRoot, stamp)
545
565
  mkdirSync(exportDir, { recursive: true })
546
566
  for (let index = 0; index < files.length; index += 1) {
547
567
  const file = files[index]
548
568
  const safeName = String(file.filename).replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').slice(0, 120) || file.id
549
- const target = join(exportDir, `${String(index + 1).padStart(2, '0')}-${safeName.endsWith('.md') ? safeName : `${safeName}.md`}`)
569
+ const baseName = safeName.endsWith('.md') ? safeName : `${safeName}.md`
570
+ const target = join(exportDir, files.length > 1 ? `${String(index + 1).padStart(2, '0')}-${baseName}` : baseName)
550
571
  writeFileSync(target, file.content, 'utf8')
551
572
  savedPaths.push(target)
552
573
  }
553
- } catch {
574
+ } catch (error) {
554
575
  // Filesystem failure must not break the export response; the inline
555
- // files still let the browser download attempt proceed.
576
+ // files still let the browser download attempt proceed. A selected
577
+ // directory failure is reported so the user can correct the path.
578
+ if (requestedDir !== undefined) {
579
+ throw badRequest(`cannot write to outputDir: ${error instanceof Error ? error.message : String(error)}`, {
580
+ field: 'outputDir',
581
+ })
582
+ }
556
583
  }
557
584
  }
558
585
  // Export is a read-only projection: no revision bump.