@lievclouds/dsh-cortex 0.5.4 → 1.0.0

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.

Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/client.js +434 -412
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -110
  7. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  8. package/src/cortex-ui/renderer/App.tsx +39 -0
  9. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  10. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  11. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  12. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  13. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  14. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +291 -137
  16. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  17. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  18. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  19. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  20. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  21. package/src/cortex-ui/renderer/i18n/messages/retest.ts +144 -2
  22. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  23. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  24. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  25. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  26. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  27. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -17
  28. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  29. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  30. package/src/host/cortex/index.js +42 -1
  31. package/src/host/cortex/repeater-store.js +411 -0
  32. package/src/host/cortex/repeater.js +15 -2
  33. package/src/host/cortex/tools.js +2 -0
  34. package/src/host/miniapp/analyze.js +1078 -0
  35. package/src/host/miniapp/assets-import.js +294 -0
  36. package/src/host/miniapp/detect.js +426 -0
  37. package/src/host/miniapp/format.js +263 -0
  38. package/src/host/miniapp/roots.js +360 -0
  39. package/src/host/miniapp/routes.js +304 -0
  40. package/src/host/miniapp/service.js +559 -0
  41. package/src/host/miniapp/store.js +756 -0
  42. package/src/host/miniapp/tools.js +335 -0
  43. package/src/host/miniapp/unpack.js +1010 -0
  44. package/src/host/miniapp/wxapkg.js +416 -0
@@ -40,6 +40,7 @@ import * as fs from './fs.js'
40
40
  import * as misc from './misc.js'
41
41
  import * as settings from './settings.js'
42
42
  import * as repeater from './repeater.js'
43
+ import * as repeaterStore from './repeater-store.js'
43
44
  import * as snapshots from './snapshots.js'
44
45
  import * as vulns from './vulns.js'
45
46
 
@@ -117,6 +118,28 @@ const ROUTES = [
117
118
 
118
119
  { pattern: /^\/repeater\/send$/, methods: { POST: repeater.handleSend } },
119
120
 
121
+ // Single-send history/collections. All state is local JSON under
122
+ // CORTEX_REPEATER_DIR / ~/.cortex/repeater, so these routes must answer even
123
+ // when no compatible database is open (`db: false`).
124
+ {
125
+ pattern: /^\/repeater\/history$/,
126
+ methods: { GET: repeaterStore.handleHistoryList, DELETE: repeaterStore.handleHistoryClear },
127
+ db: false,
128
+ },
129
+ {
130
+ pattern: /^\/repeater\/collections$/,
131
+ methods: {
132
+ GET: repeaterStore.handleCollectionsList,
133
+ POST: repeaterStore.handleCollectionsCreate,
134
+ },
135
+ db: false,
136
+ },
137
+ {
138
+ pattern: /^\/repeater\/collections\/([^/]+)$/,
139
+ methods: { DELETE: repeaterStore.handleCollectionsDelete },
140
+ db: false,
141
+ },
142
+
120
143
  // Directory browser for the export-path picker (read-only).
121
144
  { pattern: /^\/fs\/list$/, methods: { GET: fs.handleList }, db: false },
122
145
  // Remembered export defaults for the export dialog (plugin-local settings).
@@ -231,7 +254,10 @@ async function loadDelegatedHandler(moduleName, exportName) {
231
254
  const candidate =
232
255
  (mod && (mod[exportName] || mod.handle || mod.default)) || null
233
256
  return typeof candidate === 'function' ? candidate : null
234
- } catch {
257
+ } catch (err) {
258
+ // Coarse diagnostics only: never log request data or file contents.
259
+ const kind = err && typeof err.code === 'string' ? err.code : err && err.name ? err.name : 'error'
260
+ console.warn(`[dsh-cortex] delegated module ${moduleName} is unavailable (${String(kind).slice(0, 80)})`)
235
261
  return null
236
262
  }
237
263
  }
@@ -374,6 +400,21 @@ export async function handleCortex(deps) {
374
400
  return
375
401
  }
376
402
 
403
+ // Mini-program (wxapkg) reverse-engineering workbench. Delegated lazily so a
404
+ // failure in the new modules can never take down the rest of Cortex, and so
405
+ // its routes work even without a compatible Cortex data.db (they use their
406
+ // own ~/.cortex/plugin.db).
407
+ if (path === '/miniapps' || path.startsWith('/miniapps/')) {
408
+ const handler = await loadDelegatedHandler('../miniapp/routes', 'handleMiniapps')
409
+ if (!handler) {
410
+ misc.sendUnsupported(res, 'mini-program workbench is not available in this host')
411
+ return
412
+ }
413
+ const handled = await runDelegated(handler, ctx)
414
+ if (!handled) throw notFound('route not found')
415
+ return
416
+ }
417
+
377
418
  /* ----------------------------- route table --------------------------- */
378
419
 
379
420
  const { route, params } = matchRoute(path, method)
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Local Repeater state: single-send history + named request collections.
3
+ *
4
+ * Contract (`.cache/fuzzer-contract.md`):
5
+ * - root = `process.env.CORTEX_REPEATER_DIR || ~/.cortex/repeater`, resolved
6
+ * lazily (never at import time, so tests can point it at a temp dir)
7
+ * - `history.json` / `collections.json`: plain JSON arrays, newest first,
8
+ * max 50 entries each, every write atomic (`.tmp` + rename)
9
+ * - response bodies are stored capped at 256 KiB with a `truncated` flag
10
+ * - no SQLite tables, no user-dir writes outside the resolved root
11
+ *
12
+ * Nothing in this module logs request/response content, cookies or auth
13
+ * headers. Only request/response *shapes* are validated here; the actual
14
+ * outbound path stays `src/host/retest.js`.
15
+ */
16
+
17
+ import { randomUUID } from 'node:crypto'
18
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
19
+ import { homedir } from 'node:os'
20
+ import { dirname, join, resolve } from 'node:path'
21
+ import {
22
+ badRequest,
23
+ queryInt,
24
+ readJsonBody,
25
+ requireBodyString,
26
+ safeDecode,
27
+ sendJson,
28
+ } from '../http.js'
29
+ import {
30
+ ALLOWED_METHODS,
31
+ MAX_HEADERS,
32
+ MAX_HEADER_VALUE,
33
+ MAX_SEND_BODY_BYTES,
34
+ normalizeHeaders,
35
+ } from '../retest.js'
36
+
37
+ /** Max history / collection entries kept on disk (contract). */
38
+ export const MAX_REPEATER_ITEMS = 50
39
+
40
+ /** Response bodies are stored/returned at most 256 KiB (contract). */
41
+ export const MAX_STORED_BODY_BYTES = 256 * 1024
42
+
43
+ const MAX_HISTORY_LIMIT = MAX_REPEATER_ITEMS
44
+ const DEFAULT_HISTORY_LIMIT = 20
45
+ const MAX_URL_LENGTH = 8000
46
+ const MAX_NAME_LENGTH = 80
47
+ const MAX_RESPONSE_HEADERS = MAX_HEADERS * 2
48
+ const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
49
+
50
+ /* ------------------------------------------------------------------------- */
51
+ /* Paths / atomic JSON */
52
+ /* ------------------------------------------------------------------------- */
53
+
54
+ /**
55
+ * Resolve the repeater state root lazily: the environment is read on every
56
+ * call so a test (or an operator) can redirect it before the first write.
57
+ * @returns {string} absolute path
58
+ */
59
+ export function repeaterRoot() {
60
+ const configured = process.env.CORTEX_REPEATER_DIR
61
+ if (typeof configured === 'string' && configured.trim().length > 0) {
62
+ return resolve(configured.trim())
63
+ }
64
+ return join(homedir(), '.cortex', 'repeater')
65
+ }
66
+
67
+ /** @returns {string} */
68
+ export function historyFilePath() {
69
+ return join(repeaterRoot(), 'history.json')
70
+ }
71
+
72
+ /** @returns {string} */
73
+ export function collectionsFilePath() {
74
+ return join(repeaterRoot(), 'collections.json')
75
+ }
76
+
77
+ /**
78
+ * Read a JSON document defensively: missing/corrupt files degrade to `null`.
79
+ * @param {string} filePath
80
+ * @returns {any}
81
+ */
82
+ export function readJsonFile(filePath) {
83
+ try {
84
+ const text = readFileSync(filePath, 'utf8')
85
+ if (text.length === 0) return null
86
+ return JSON.parse(text)
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Write a JSON document atomically: `<file>.tmp` then rename into place.
94
+ * @param {string} filePath
95
+ * @param {unknown} value
96
+ */
97
+ export function writeJsonAtomic(filePath, value) {
98
+ const dir = dirname(filePath)
99
+ mkdirSync(dir, { recursive: true })
100
+ const tempPath = `${filePath}.tmp`
101
+ try {
102
+ writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8')
103
+ renameSync(tempPath, filePath)
104
+ } catch (err) {
105
+ try {
106
+ rmSync(tempPath, { force: true })
107
+ } catch {
108
+ /* best-effort cleanup */
109
+ }
110
+ throw err
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Cut a text value to the stored-body byte cap on a UTF-8 boundary.
116
+ * @param {unknown} value
117
+ * @returns {{body: string, truncated: boolean}}
118
+ */
119
+ export function capStoredBody(value) {
120
+ const text = value === null || value === undefined ? '' : String(value)
121
+ const buffer = Buffer.from(text, 'utf8')
122
+ if (buffer.length <= MAX_STORED_BODY_BYTES) return { body: text, truncated: false }
123
+ let end = MAX_STORED_BODY_BYTES
124
+ // Do not cut in the middle of a multi-byte UTF-8 sequence.
125
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end -= 1
126
+ return { body: buffer.subarray(0, end).toString('utf8'), truncated: true }
127
+ }
128
+
129
+ /* ------------------------------------------------------------------------- */
130
+ /* History */
131
+ /* ------------------------------------------------------------------------- */
132
+
133
+ /**
134
+ * @param {unknown} value
135
+ * @returns {Record<string, any>|null}
136
+ */
137
+ function plainObject(value) {
138
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null
139
+ }
140
+
141
+ /**
142
+ * Read the stored history array (newest first). Corrupt/missing files -> [].
143
+ * @returns {Record<string, any>[]}
144
+ */
145
+ export function loadHistory() {
146
+ return loadArray(historyFilePath())
147
+ }
148
+
149
+ /** @returns {Record<string, any>[]} */
150
+ export function loadCollections() {
151
+ return loadArray(collectionsFilePath())
152
+ }
153
+
154
+ /** @param {string} filePath @returns {Record<string, any>[]} */
155
+ function loadArray(filePath) {
156
+ const parsed = readJsonFile(filePath)
157
+ const raw = Array.isArray(parsed)
158
+ ? parsed
159
+ : parsed && Array.isArray(parsed.items)
160
+ ? parsed.items
161
+ : []
162
+ return raw.filter((item) => plainObject(item) !== null)
163
+ }
164
+
165
+ /** @param {string} filePath @param {unknown[]} items */
166
+ function saveArray(filePath, items) {
167
+ writeJsonAtomic(filePath, items.slice(0, MAX_REPEATER_ITEMS))
168
+ }
169
+
170
+ /**
171
+ * Normalize one reusable request object (shared `RepeaterSendInput` shape).
172
+ * Header rules are delegated to `normalizeHeaders`, so this never re-implements
173
+ * the protocol-header / value validation from `retest.js`.
174
+ * @param {unknown} value
175
+ * @param {string} field
176
+ */
177
+ export function normalizeStoredRequest(value, field = 'request') {
178
+ const source = plainObject(value)
179
+ if (!source) throw badRequest(`${field} must be an object`, { field })
180
+ const method = source.method
181
+ if (typeof method !== 'string' || !ALLOWED_METHODS.includes(method)) {
182
+ throw badRequest(`${field}.method must be an uppercase HTTP method`, { field: `${field}.method` })
183
+ }
184
+ const url = source.url
185
+ if (typeof url !== 'string' || url.length === 0) {
186
+ throw badRequest(`${field}.url is required`, { field: `${field}.url` })
187
+ }
188
+ if (url.length > MAX_URL_LENGTH) {
189
+ throw badRequest(`${field}.url is too long`, { field: `${field}.url`, max: MAX_URL_LENGTH })
190
+ }
191
+ const headers = normalizeHeaders(source.headers)
192
+ let body = ''
193
+ if (source.body !== undefined && source.body !== null) {
194
+ if (typeof source.body !== 'string') {
195
+ throw badRequest(`${field}.body must be a string`, { field: `${field}.body` })
196
+ }
197
+ if (Buffer.byteLength(source.body, 'utf8') > MAX_SEND_BODY_BYTES) {
198
+ throw badRequest(`${field}.body is too large`, {
199
+ field: `${field}.body`,
200
+ maxBytes: MAX_SEND_BODY_BYTES,
201
+ })
202
+ }
203
+ body = source.body
204
+ }
205
+ return { method, url, headers, body }
206
+ }
207
+
208
+ /**
209
+ * Normalize one response object (shared `RepeaterSendResult.response` shape).
210
+ * The body is capped at 256 KiB and the `truncated` flag reflects that cut.
211
+ * @param {unknown} value
212
+ * @param {string} field
213
+ */
214
+ export function normalizeStoredResponse(value, field = 'response') {
215
+ const source = plainObject(value)
216
+ if (!source) throw badRequest(`${field} must be an object`, { field })
217
+ const status = source.status
218
+ if (!Number.isInteger(status) || status < 0 || status > 999) {
219
+ throw badRequest(`${field}.status must be an integer status code`, { field: `${field}.status` })
220
+ }
221
+ const headersSource = source.headers === undefined ? {} : source.headers
222
+ const headersObject = plainObject(headersSource)
223
+ if (!headersObject) throw badRequest(`${field}.headers must be an object`, { field: `${field}.headers` })
224
+ const entries = Object.entries(headersObject)
225
+ if (entries.length > MAX_RESPONSE_HEADERS) {
226
+ throw badRequest(`${field}.headers has too many entries`, {
227
+ field: `${field}.headers`,
228
+ max: MAX_RESPONSE_HEADERS,
229
+ })
230
+ }
231
+ /** @type {Record<string, string>} */
232
+ const headers = {}
233
+ for (const [name, headerValue] of entries) {
234
+ if (!HEADER_NAME_RE.test(name)) {
235
+ throw badRequest(`${field}.headers has an invalid name`, { field: `${field}.headers` })
236
+ }
237
+ if (typeof headerValue !== 'string' || headerValue.length > MAX_HEADER_VALUE) {
238
+ throw badRequest(`${field}.headers has an invalid value`, { field: `${field}.headers` })
239
+ }
240
+ headers[name] = headerValue
241
+ }
242
+ if (typeof source.body !== 'string') {
243
+ throw badRequest(`${field}.body must be a string`, { field: `${field}.body` })
244
+ }
245
+ const durationMs = source.durationMs
246
+ if (typeof durationMs !== 'number' || !Number.isFinite(durationMs) || durationMs < 0) {
247
+ throw badRequest(`${field}.durationMs must be a non-negative number`, {
248
+ field: `${field}.durationMs`,
249
+ })
250
+ }
251
+ const declaredTruncated = source.truncated
252
+ if (declaredTruncated !== undefined && typeof declaredTruncated !== 'boolean') {
253
+ throw badRequest(`${field}.truncated must be a boolean`, { field: `${field}.truncated` })
254
+ }
255
+ const capped = capStoredBody(source.body)
256
+ return {
257
+ status,
258
+ headers,
259
+ body: capped.body,
260
+ durationMs,
261
+ truncated: declaredTruncated === true || capped.truncated,
262
+ }
263
+ }
264
+
265
+ /** Build a display-only raw HTTP request text from the request shape. */
266
+ function rawRequestText(request) {
267
+ let target = String(request.url || '')
268
+ try {
269
+ const url = new URL(target)
270
+ target = `${url.pathname}${url.search}`
271
+ } catch {
272
+ /* keep the verbatim value */
273
+ }
274
+ const lines = [`${request.method} ${target} HTTP/1.1`]
275
+ for (const [name, value] of Object.entries(request.headers || {})) {
276
+ lines.push(`${name}: ${value}`)
277
+ }
278
+ const body = typeof request.body === 'string' ? request.body : ''
279
+ return `${lines.join('\r\n')}\r\n\r\n${body}`
280
+ }
281
+
282
+ /**
283
+ * Append a history entry after a successful single Repeater send.
284
+ *
285
+ * Best-effort by design: any disk failure returns `null` instead of throwing,
286
+ * so a history write can never fail the actual send. Request/response bodies
287
+ * are stored (capped), but this function never logs them.
288
+ *
289
+ * @param {{vulnId?: string, request: Record<string, any>, result: Record<string, any>}} input
290
+ * @returns {Record<string, any>|null}
291
+ */
292
+ export function recordSendHistory(input) {
293
+ try {
294
+ const source = input && typeof input === 'object' ? input : {}
295
+ const request = source.request || {}
296
+ const result = source.result || {}
297
+ const response = normalizeStoredResponse({
298
+ status: Number(result.status) || 0,
299
+ headers: plainObject(result.headers) || {},
300
+ body: typeof result.body === 'string' ? result.body : '',
301
+ durationMs: Number(result.durationMs) || 0,
302
+ truncated: result.bodyTruncated === true,
303
+ })
304
+ const normalizedRequest = {
305
+ method: String(request.method || ''),
306
+ url: request.url instanceof URL ? request.url.href : String(request.url || ''),
307
+ headers: plainObject(request.headers) || {},
308
+ body: request.body === undefined || request.body === null ? '' : String(request.body),
309
+ }
310
+ const entry = {
311
+ id: randomUUID(),
312
+ createdAt: new Date().toISOString(),
313
+ vulnId: typeof source.vulnId === 'string' ? source.vulnId : '',
314
+ raw: rawRequestText(normalizedRequest),
315
+ request: normalizedRequest,
316
+ response,
317
+ }
318
+ const items = loadHistory()
319
+ items.unshift(entry)
320
+ saveArray(historyFilePath(), items)
321
+ return entry
322
+ } catch {
323
+ return null
324
+ }
325
+ }
326
+
327
+ /* ------------------------------------------------------------------------- */
328
+ /* Handlers — history */
329
+ /* ------------------------------------------------------------------------- */
330
+
331
+ /** GET /repeater/history?limit=20 -> { items } */
332
+ export function handleHistoryList({ res, searchParams }) {
333
+ const limit =
334
+ queryInt(searchParams, 'limit', { min: 1, max: MAX_HISTORY_LIMIT }) ?? DEFAULT_HISTORY_LIMIT
335
+ const items = loadHistory().slice(0, limit)
336
+ sendJson(res, 200, { items })
337
+ }
338
+
339
+ /** DELETE /repeater/history -> { cleared: true } */
340
+ export function handleHistoryClear({ res }) {
341
+ saveArray(historyFilePath(), [])
342
+ sendJson(res, 200, { cleared: true })
343
+ }
344
+
345
+ /* ------------------------------------------------------------------------- */
346
+ /* Handlers — collections */
347
+ /* ------------------------------------------------------------------------- */
348
+
349
+ /** GET /repeater/collections -> { items } */
350
+ export function handleCollectionsList({ res }) {
351
+ sendJson(res, 200, { items: loadCollections() })
352
+ }
353
+
354
+ /**
355
+ * POST /repeater/collections
356
+ * Body: `{name, vulnId, raw, request, response?}` -> `{ item }`
357
+ */
358
+ export async function handleCollectionsCreate({ req, res }) {
359
+ const body = await readJsonBody(req)
360
+ // Contract: the *trimmed* name is 1..80 characters.
361
+ const rawName = requireBodyString(body, 'name', { max: 200 })
362
+ const name = rawName.trim()
363
+ if (name.length === 0) {
364
+ throw badRequest('name must not be empty', { field: 'name' })
365
+ }
366
+ if (name.length > MAX_NAME_LENGTH) {
367
+ throw badRequest('name is too long', { field: 'name', max: MAX_NAME_LENGTH })
368
+ }
369
+ let vulnId = ''
370
+ if (body.vulnId !== undefined && body.vulnId !== null) {
371
+ vulnId = requireBodyString(body, 'vulnId', { max: 200 })
372
+ }
373
+ let raw = ''
374
+ if (body.raw !== undefined && body.raw !== null) {
375
+ if (typeof body.raw !== 'string') throw badRequest('raw must be a string', { field: 'raw' })
376
+ raw = body.raw
377
+ }
378
+ const request = normalizeStoredRequest(body.request, 'request')
379
+ let response = null
380
+ if (body.response !== undefined && body.response !== null) {
381
+ response = normalizeStoredResponse(body.response, 'response')
382
+ }
383
+
384
+ const items = loadCollections()
385
+ if (items.length >= MAX_REPEATER_ITEMS) {
386
+ throw badRequest('collection is full', { field: 'name', max: MAX_REPEATER_ITEMS })
387
+ }
388
+ const item = {
389
+ id: randomUUID(),
390
+ name,
391
+ createdAt: new Date().toISOString(),
392
+ vulnId,
393
+ raw,
394
+ request,
395
+ response,
396
+ }
397
+ items.unshift(item)
398
+ saveArray(collectionsFilePath(), items)
399
+ sendJson(res, 200, { item })
400
+ }
401
+
402
+ /** DELETE /repeater/collections/:id -> { deleted: boolean } */
403
+ export function handleCollectionsDelete({ res, params }) {
404
+ const id = safeDecode(params[0], 'collection id')
405
+ if (id.length === 0) throw badRequest('collection id is required')
406
+ const items = loadCollections()
407
+ const next = items.filter((item) => item.id !== id)
408
+ const deleted = next.length !== items.length
409
+ if (deleted) saveArray(collectionsFilePath(), next)
410
+ sendJson(res, 200, { deleted })
411
+ }
@@ -10,11 +10,14 @@
10
10
  * result onto the exact shared `RepeaterSendResult` shape.
11
11
  *
12
12
  * The renderer's RepeaterSendInput uses camelCase; the shared schema accepts
13
- * that. Request headers/bodies are never logged.
13
+ * that. Request headers/bodies are never logged. A successful send also
14
+ * appends one best-effort entry to the local Repeater history (single-send
15
+ * convenience; a disk failure never changes the response).
14
16
  */
15
17
 
16
18
  import { ApiError, readJsonBody, sendJson } from '../http.js'
17
19
  import { sendRetest, validateSendRequest } from '../retest.js'
20
+ import { recordSendHistory } from './repeater-store.js'
18
21
 
19
22
  /**
20
23
  * @param {{req: import('node:http').IncomingMessage,
@@ -32,7 +35,7 @@ export async function handleSend({ req, res, config, repository }) {
32
35
  const context = repository.getRetestContext(request.vulnId)
33
36
  const result = await sendRetest({ request, context })
34
37
 
35
- sendJson(res, 200, {
38
+ const responsePayload = {
36
39
  request: {
37
40
  method: request.method,
38
41
  url: request.url instanceof URL ? request.url.href : String(request.url),
@@ -46,5 +49,15 @@ export async function handleSend({ req, res, config, repository }) {
46
49
  durationMs: result.durationMs,
47
50
  truncated: result.bodyTruncated === true,
48
51
  },
52
+ }
53
+
54
+ // Best-effort local history (contract): a disk failure must never fail the
55
+ // send, and the response shape returned to the renderer stays unchanged.
56
+ // Respond first so the renderer never waits for the history write.
57
+ sendJson(res, 200, responsePayload)
58
+ recordSendHistory({
59
+ vulnId: request.vulnId,
60
+ request: responsePayload.request,
61
+ result,
49
62
  })
50
63
  }
@@ -14,6 +14,7 @@ import {
14
14
  normalizeTestParameters,
15
15
  } from './normalize-asset.js'
16
16
  import { auditCompleteness, syncInternalScope } from './data-tools.js'
17
+ import { createMiniappToolDefinitions } from '../miniapp/tools.js'
17
18
 
18
19
  const SEVERITIES = ['Critical', 'High', 'Medium', 'Low', 'Info']
19
20
  const STATUSES = ['Confirmed', 'Inconclusive', 'Retest_Passed']
@@ -981,6 +982,7 @@ export function createCortexToolDefinitions({ holder, config }) {
981
982
  graphSummary,
982
983
  auditTool,
983
984
  internalSyncTool,
985
+ ...createMiniappToolDefinitions({ holder, config }),
984
986
  ]
985
987
  return readOnly ? tools.map((tool) => ({ ...tool, description: `${tool.description} (current host: read-only)` })) : tools
986
988
  }