@goodandready/dsh-moa 0.2.37 → 0.2.38

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.
@@ -2,7 +2,12 @@ import { bestEffort } from './best-effort.js'
2
2
  import { logger } from './logger.js'
3
3
  import vm from 'node:vm'
4
4
  import fs from 'node:fs/promises'
5
+ import fsSync from 'node:fs'
5
6
  import path from 'node:path'
7
+ /**
8
+ * In-memory serialization mutex to prevent interleaved promotions to the same baseDir.
9
+ */
10
+ const promotionLocks = new Map()
6
11
 
7
12
  /**
8
13
  * Validates that an untrusted relative path is safe and does not traverse upwards.
@@ -12,12 +17,12 @@ export function isSafeRelativePath(relPath) {
12
17
  const trimmed = relPath.trim()
13
18
  if (!trimmed || path.isAbsolute(trimmed) || trimmed.startsWith('/') || trimmed.startsWith('\\')) return false
14
19
  const normalized = path.normalize(trimmed)
15
- if (!normalized || normalized === '.' || normalized.startsWith('..') || path.isAbsolute(normalized)) return false
16
- return true
20
+ return !(!normalized || normalized === '.' || normalized.startsWith('..') || path.isAbsolute(normalized))
17
21
  }
18
22
 
19
23
  /**
20
- * Asserts that a resolved path is strictly contained within baseDir.
24
+ * Asserts that a resolved path is strictly contained within baseDir, verifying
25
+ * both lexical boundaries and physical canonical symlink resolution.
21
26
  */
22
27
  export function assertPathContained(baseDir, subPath) {
23
28
  const resolvedBase = path.resolve(baseDir)
@@ -25,18 +30,66 @@ export function assertPathContained(baseDir, subPath) {
25
30
  if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + path.sep)) {
26
31
  throw new Error(`Path traversal violation: "${subPath}" escapes base directory "${baseDir}"`)
27
32
  }
33
+
34
+ try {
35
+ const realBase = fsSync.realpathSync(resolvedBase)
36
+ try {
37
+ const realTarget = fsSync.realpathSync(resolvedTarget)
38
+ if (realTarget !== realBase && !realTarget.startsWith(realBase + path.sep)) {
39
+ throw new Error(`Path traversal violation: symlink "${subPath}" resolves outside "${baseDir}"`)
40
+ }
41
+ } catch (targetErr) {
42
+ if (targetErr?.code === 'ENOENT') {
43
+ let curr = path.dirname(resolvedTarget)
44
+ while (curr === resolvedBase || curr.startsWith(resolvedBase + path.sep)) {
45
+ try {
46
+ const realCurr = fsSync.realpathSync(curr)
47
+ if (realCurr !== realBase && !realCurr.startsWith(realBase + path.sep)) {
48
+ throw new Error(`Path traversal violation: symlink ancestor in "${subPath}" resolves outside "${baseDir}"`)
49
+ }
50
+ break
51
+ } catch (ancestorErr) {
52
+ if (ancestorErr?.code === 'ENOENT') {
53
+ const parent = path.dirname(curr)
54
+ if (parent === curr) break
55
+ curr = parent
56
+ continue
57
+ }
58
+ throw ancestorErr
59
+ }
60
+ }
61
+ } else {
62
+ throw targetErr
63
+ }
64
+ }
65
+ } catch (baseErr) {
66
+ if (baseErr?.message?.startsWith('Path traversal violation')) throw baseErr
67
+ }
68
+
28
69
  return resolvedTarget
29
70
  }
30
71
 
72
+ /**
73
+ * Resolves directory paths for candidate sandboxes with optional runId scoping.
74
+ */
75
+ export function resolveCandidateFolder(baseDir, candidateTarget, options = {}) {
76
+ const targetStr = String(candidateTarget ?? 1).trim()
77
+ const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
78
+ const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
79
+ const rawRunId = typeof options === 'string' ? options : options?.runId
80
+ const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
81
+ const moaRoot = path.join(baseDir, '.moa')
82
+ const runRoot = safeRunId ? assertPathContained(moaRoot, safeRunId) : moaRoot
83
+ return { moaRoot, runRoot, safeRunId, sub, candidateDir: assertPathContained(runRoot, sub) }
84
+ }
85
+
31
86
  /**
32
87
  * Parses markdown code blocks that specify file paths.
33
88
  */
34
89
  export function extractFileBlocks(text) {
35
90
  if (!text || typeof text !== 'string') return []
36
-
37
91
  const files = []
38
92
  const seenPaths = new Set()
39
-
40
93
  const addFile = (rawPath, fileContent) => {
41
94
  if (rawPath && isSafeRelativePath(rawPath) && !seenPaths.has(rawPath)) {
42
95
  seenPaths.add(rawPath)
@@ -44,24 +97,19 @@ export function extractFileBlocks(text) {
44
97
  }
45
98
  }
46
99
 
47
- // Pattern 1: fenced blocks with file="..." or path="..." in info string
48
100
  const fenceRegex = /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi
49
101
  let match
50
102
  while ((match = fenceRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
51
103
 
52
- // Pattern 2: comment header: // File: path/to/file.ext or <!-- File: ... -->
53
104
  const commentRegex = /(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
54
105
  while ((match = commentRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
55
106
 
56
- // Pattern 3: markdown header: ### File: path/to/file.ext
57
107
  const headerRegex = /#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
58
108
  while ((match = headerRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
59
109
 
60
- // Pattern 4: first line inside code block: // File: path/to/file.ext
61
110
  const inlineRegex = /```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi
62
111
  while ((match = inlineRegex.exec(text)) !== null) addFile(match[1].trim(), match[2])
63
112
 
64
- // Fallback: single anonymous code block if it contains complete HTML
65
113
  if (files.length === 0) {
66
114
  const htmlBlockRegex = /```(?:html)?[\r\n]+([\s\S]*?)```/gi
67
115
  let htmlMatch
@@ -73,7 +121,6 @@ export function extractFileBlocks(text) {
73
121
  }
74
122
  }
75
123
  }
76
-
77
124
  return files
78
125
  }
79
126
 
@@ -99,13 +146,8 @@ export function formatProjectContext(projectFiles = [], options = {}) {
99
146
 
100
147
  export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
101
148
  if (!baseDir) return { files: [], totalChars: 0, skippedFiles: 0, skippedList: [] }
102
-
103
149
  const ignoreDirs = new Set(['node_modules', '.git', '.moa', '.worktrees', 'dist', 'build', '.dsh', '.next', '.nuxt', '.cache', 'coverage', '.turbo', '.idea', '.vscode'])
104
- const codeExts = new Set([
105
- '.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
106
- '.vue', '.svelte', '.json', '.py', '.md', '.txt', '.sh', '.yaml', '.yml',
107
- ])
108
-
150
+ const codeExts = new Set(['.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.json', '.py', '.md', '.txt', '.sh', '.yaml', '.yml'])
109
151
  const collected = []
110
152
  let totalChars = 0
111
153
  let skippedFiles = 0
@@ -173,57 +215,48 @@ export function isRefinementTask(userPrompt = '', projectFiles = null) {
173
215
  const p = userPrompt.toLowerCase().trim()
174
216
  if (!p) return false
175
217
 
176
- // Greenfield creation phrases - ALWAYS fresh task
177
- const freshPhrases = [
178
- 'from scratch', 'new project', 'new app', 'create a new', 'build a new', 'generate a new',
179
- '从头开始', '新建项目', '创建项目', '新应用', '新建应用',
180
- 'с нуля', 'новый проект', 'новое приложение', 'создай проект', 'создай приложение', 'создай игру', 'создай сервис',
181
- 'сделай проект', 'сделай приложение', 'сделай игру',
182
- ]
183
- if (freshPhrases.some((phrase) => p.includes(phrase))) {
184
- return false
185
- }
186
-
187
- // Modification keywords
188
- const modKeywords = [
189
- 'add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust',
190
- 'style', 'css', 'color', 'theme', 'bug',
191
- '添加', '修改', '更新', '修复', '重构', '改进', '调整', '样式', '颜色', '主题',
192
- 'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали',
193
- 'темн', 'светл', 'цвет', 'кнопк', 'баг',
194
- ]
218
+ const freshPhrases = ['from scratch', 'new project', 'new app', 'create a new', 'build a new', 'generate a new', '从头开始', '新建项目', '创建项目', '新应用', '新建应用', 'с нуля', 'новый проект', 'новое приложение', 'создай проект', 'создай приложение', 'создай игру', 'создай сервис', 'сделай проект', 'сделай приложение', 'сделай игру']
219
+ if (freshPhrases.some((phrase) => p.includes(phrase))) return false
195
220
 
221
+ const modKeywords = ['add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust', 'style', 'css', 'color', 'theme', 'bug', '添加', '修改', '更新', '修复', '重构', '改进', '调整', '样式', '颜色', '主题', 'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали', 'темн', 'светл', 'цвет', 'кнопк', 'баг']
196
222
  return modKeywords.some((kw) => p.includes(kw))
197
223
  }
198
224
 
199
225
  /**
200
- * Writes candidate files into an isolated candidate workspace:
201
- * <baseDir>/.moa/candidate-<index>/
226
+ * Writes candidate files into an isolated candidate workspace.
227
+ * Uses atomic staging and swap to prevent stale file accumulation and dirty snapshots.
202
228
  */
203
- export async function writeCandidateWorkspace(baseDir, candidateIndex, files = []) {
229
+ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [], options = {}) {
204
230
  if (!baseDir || !Array.isArray(files) || files.length === 0) return []
231
+ const { runRoot, sub, candidateDir } = resolveCandidateFolder(baseDir, candidateIndex, options)
232
+ await fs.mkdir(runRoot, { recursive: true })
205
233
 
206
- const targetStr = String(candidateIndex).trim()
207
- const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
208
- const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
209
- const moaRoot = path.join(baseDir, '.moa')
210
- const candidateDir = assertPathContained(moaRoot, sub)
211
- await fs.mkdir(candidateDir, { recursive: true })
234
+ const tempSub = `.tmp-${sub}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
235
+ const tempCandidateDir = assertPathContained(runRoot, tempSub)
236
+ await fs.mkdir(tempCandidateDir, { recursive: true })
212
237
 
213
- const written = []
214
- for (const file of files) {
215
- if (!isSafeRelativePath(file.relativePath)) continue
216
- const fullPath = assertPathContained(candidateDir, file.relativePath)
217
- await fs.mkdir(path.dirname(fullPath), { recursive: true })
218
- await fs.writeFile(fullPath, file.content, 'utf8')
219
- written.push({
220
- relativePath: file.relativePath,
221
- fullPath,
222
- size: Buffer.byteLength(file.content, 'utf8'),
223
- })
224
- }
238
+ try {
239
+ const written = []
240
+ for (const file of files) {
241
+ if (!isSafeRelativePath(file.relativePath)) continue
242
+ const fullPath = assertPathContained(tempCandidateDir, file.relativePath)
243
+ await fs.mkdir(path.dirname(fullPath), { recursive: true })
244
+ await fs.writeFile(fullPath, file.content, 'utf8')
245
+ written.push({
246
+ relativePath: file.relativePath,
247
+ fullPath: path.join(candidateDir, file.relativePath),
248
+ size: Buffer.byteLength(file.content, 'utf8'),
249
+ })
250
+ }
225
251
 
226
- return written
252
+ if (written.length === 0 && files.length > 0) throw new Error('No valid files could be written to candidate workspace')
253
+ await fs.rm(candidateDir, { recursive: true, force: true })
254
+ await fs.rename(tempCandidateDir, candidateDir)
255
+ return written
256
+ } catch (err) {
257
+ await bestEffort('clean tempCandidateDir on error', () => fs.rm(tempCandidateDir, { recursive: true, force: true }))
258
+ throw err
259
+ }
227
260
  }
228
261
 
229
262
  /**
@@ -238,8 +271,6 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
238
271
  }
239
272
 
240
273
  const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
241
-
242
- // 1. Direct Cordis timeMachineEngine service call if available
243
274
  if (options.timeMachineEngine && typeof options.timeMachineEngine.createSnapshot === 'function') {
244
275
  const snap = await bestEffort('dsh-time-machine direct service createSnapshot', async () => {
245
276
  return await options.timeMachineEngine.createSnapshot(label, { cwd: baseDir })
@@ -247,7 +278,6 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
247
278
  if (snap) return snap
248
279
  }
249
280
 
250
- // 2. HTTP endpoint call if port or timeMachineUrl is provided
251
281
  const port = options.port || options.webServerPort || process.env.DSH_PORT || process.env.PORT
252
282
  if (!port && !options.timeMachineUrl) {
253
283
  logger.warn('[dsh-moa] Cannot create pre-promotion checkpoint: webServerPort is unknown and no timeMachineUrl provided')
@@ -255,7 +285,6 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
255
285
  }
256
286
 
257
287
  const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
258
-
259
288
  const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
260
289
  const res = await fetch(endpoint, {
261
290
  method: 'POST',
@@ -272,20 +301,39 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
272
301
  return snap || null
273
302
  }
274
303
 
304
+ /**
305
+ * Promotes winning candidate workspace files into the base workspace.
306
+ * Serialized per baseDir and verifies symlink boundaries on promotion.
307
+ */
275
308
  export async function promoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
276
309
  if (!baseDir || winningIndex === null || winningIndex === undefined) return []
277
310
 
278
- const targetStr = String(winningIndex).trim()
279
- const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
280
- const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
281
- const moaRoot = path.join(baseDir, '.moa')
282
- const winningDir = assertPathContained(moaRoot, sub)
311
+ const key = path.resolve(baseDir)
312
+ const prevLock = promotionLocks.get(key) || Promise.resolve()
313
+ let releaseLock
314
+ const currentLock = new Promise((resolve) => { releaseLock = resolve })
315
+ promotionLocks.set(key, currentLock)
316
+
317
+ try {
318
+ await prevLock
319
+ return await doPromoteCandidateWorkspace(baseDir, winningIndex, options)
320
+ } finally {
321
+ releaseLock()
322
+ if (promotionLocks.get(key) === currentLock) {
323
+ promotionLocks.delete(key)
324
+ }
325
+ }
326
+ }
327
+
328
+ async function doPromoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
329
+ const { runRoot, moaRoot, safeRunId, candidateDir: winningDir } = resolveCandidateFolder(baseDir, winningIndex, options)
330
+ const cleanupTarget = safeRunId ? runRoot : moaRoot
283
331
 
284
332
  try {
285
333
  const entries = await getFilesRecursively(winningDir)
286
334
  if (entries.length === 0) {
287
335
  if (!options.keepMoa) {
288
- await bestEffort('clean moaRoot on empty candidate', () => fs.rm(moaRoot, { recursive: true, force: true }))
336
+ await bestEffort('clean empty candidate folder', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
289
337
  }
290
338
  return []
291
339
  }
@@ -298,35 +346,61 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
298
346
  }
299
347
 
300
348
  const promoted = []
349
+ const baseReal = await fs.realpath(baseDir).catch(() => path.resolve(baseDir))
350
+
301
351
  for (const rel of entries) {
302
352
  if (!isSafeRelativePath(rel)) continue
303
353
  const srcPath = assertPathContained(winningDir, rel)
304
354
  const destPath = assertPathContained(baseDir, rel)
305
355
 
356
+ try {
357
+ const destLstat = await fs.lstat(destPath)
358
+ if (destLstat.isSymbolicLink()) {
359
+ const destReal = await fs.realpath(destPath)
360
+ if (destReal !== baseReal && !destReal.startsWith(baseReal + path.sep)) {
361
+ throw new Error(`Path traversal violation: promotion destination symlink "${rel}" resolves outside "${baseDir}"`)
362
+ }
363
+ await fs.unlink(destPath)
364
+ }
365
+ } catch (lstatErr) {
366
+ if (lstatErr?.code !== 'ENOENT') throw lstatErr
367
+ }
368
+
306
369
  await fs.mkdir(path.dirname(destPath), { recursive: true })
307
370
  await fs.copyFile(srcPath, destPath)
308
371
  promoted.push(rel)
309
372
  }
310
373
 
311
374
  if (checkpoint) promoted.checkpoint = checkpoint
312
- if (!options.keepMoa) await fs.rm(moaRoot, { recursive: true, force: true })
375
+ if (!options.keepMoa) await fs.rm(cleanupTarget, { recursive: true, force: true })
313
376
 
314
377
  return promoted
315
378
  } catch (err) {
316
379
  if (!options.keepMoa) {
317
- await bestEffort('clean moaRoot on promote error', () => fs.rm(moaRoot, { recursive: true, force: true }))
380
+ await bestEffort('clean candidate on promote error', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
318
381
  }
319
382
  throw err
320
383
  }
321
384
  }
322
385
 
323
386
  /**
324
- * Cleans up any left-over .moa candidate folder.
387
+ * Cleans up candidate workspaces, optionally scoped to a single runId.
325
388
  */
326
- export async function cleanMoaWorkspaces(baseDir) {
389
+ export async function cleanMoaWorkspaces(baseDir, options = {}) {
327
390
  if (!baseDir) return
391
+ const rawRunId = typeof options === 'string' ? options : options?.runId
392
+ const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
328
393
  const moaRoot = path.join(baseDir, '.moa')
329
- await bestEffort('cleanMoaWorkspaces', () => fs.rm(moaRoot, { recursive: true, force: true }))
394
+ if (safeRunId) {
395
+ const runRoot = path.join(moaRoot, safeRunId)
396
+ await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(runRoot, { recursive: true, force: true }))
397
+ await bestEffort('clean empty moaRoot', async () => {
398
+ const remaining = await fs.readdir(moaRoot)
399
+ if (remaining.length === 0) await fs.rmdir(moaRoot)
400
+ })
401
+ } else {
402
+ await bestEffort('cleanMoaWorkspaces', () => fs.rm(moaRoot, { recursive: true, force: true }))
403
+ }
330
404
  }
331
405
 
332
406
  async function getFilesRecursively(dir) {
@@ -338,6 +412,13 @@ async function getFilesRecursively(dir) {
338
412
  return []
339
413
  }
340
414
 
415
+ let realDir
416
+ try {
417
+ realDir = await fs.realpath(dir)
418
+ } catch {
419
+ realDir = path.resolve(dir)
420
+ }
421
+
341
422
  async function scan(current, relPrefix = '') {
342
423
  let list
343
424
  try {
@@ -345,6 +426,18 @@ async function getFilesRecursively(dir) {
345
426
  for (const item of list) {
346
427
  const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
347
428
  const full = path.join(current, item.name)
429
+
430
+ if (item.isSymbolicLink()) {
431
+ try {
432
+ const realTarget = await fs.realpath(full)
433
+ if (realTarget === realDir || realTarget.startsWith(realDir + path.sep)) {
434
+ const st = await fs.stat(full)
435
+ if (st.isFile()) results.push(rel)
436
+ }
437
+ } catch (err) { logger.debug?.('[dsh-moa] symlink inspect skipped', err) }
438
+ continue
439
+ }
440
+
348
441
  if (item.isDirectory()) await scan(full, rel)
349
442
  else if (item.isFile()) results.push(rel)
350
443
  }
@@ -377,11 +470,7 @@ export function verifyFileSyntax(files = []) {
377
470
  .replace(/^([ \t]*)export\s+\*\s+from\s+[^;\n]+;?/gm, '$1')
378
471
  new vm.Script(transformed)
379
472
  } catch (err) {
380
- warnings.push({
381
- file: file.relativePath,
382
- error: err?.message || String(err),
383
- line: err?.lineNumber || 1,
384
- })
473
+ warnings.push({ file: file.relativePath, error: err?.message || String(err), line: err?.lineNumber || 1 })
385
474
  }
386
475
  } else if (ext === '.json') {
387
476
  try {
@@ -395,23 +484,18 @@ export function verifyFileSyntax(files = []) {
395
484
  }
396
485
  }
397
486
  }
398
-
399
487
  return warnings
400
488
  }
401
489
 
402
490
  /**
403
- * Reads all files from a candidate workspace (.moa/candidate-N or .moa/curator-synthesis)
491
+ * Reads all files from a candidate workspace (.moa/candidate-N, .moa/curator-synthesis, or scoped under runId).
404
492
  */
405
- export async function readCandidateFiles(baseDir, candidateTarget) {
493
+ export async function readCandidateFiles(baseDir, candidateTarget, options = {}) {
406
494
  if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
407
- const targetStr = String(candidateTarget).trim()
408
- const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
409
- const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
410
- const moaRoot = path.join(baseDir, '.moa')
411
-
412
495
  let targetDir
413
496
  try {
414
- targetDir = assertPathContained(moaRoot, sub)
497
+ const resolved = resolveCandidateFolder(baseDir, candidateTarget, options)
498
+ targetDir = resolved.candidateDir
415
499
  } catch {
416
500
  return []
417
501
  }
@@ -419,16 +503,21 @@ export async function readCandidateFiles(baseDir, candidateTarget) {
419
503
  try {
420
504
  const relFiles = await getFilesRecursively(targetDir)
421
505
  const result = []
506
+ const realTarget = await fs.realpath(targetDir).catch(() => targetDir)
507
+
422
508
  for (const rel of relFiles) {
423
509
  if (!isSafeRelativePath(rel)) continue
424
510
  try {
425
511
  const fullPath = assertPathContained(targetDir, rel)
512
+ const st = await fs.lstat(fullPath)
513
+ if (st.isSymbolicLink()) {
514
+ const realFile = await fs.realpath(fullPath)
515
+ if (realFile !== realTarget && !realFile.startsWith(realTarget + path.sep)) {
516
+ continue
517
+ }
518
+ }
426
519
  const content = await fs.readFile(fullPath, 'utf8')
427
- result.push({
428
- relativePath: rel,
429
- content,
430
- size: Buffer.byteLength(content, 'utf8'),
431
- })
520
+ result.push({ relativePath: rel, content, size: Buffer.byteLength(content, 'utf8') })
432
521
  } catch {
433
522
  // file read error best-effort
434
523
  }
@@ -442,95 +531,59 @@ export async function readCandidateFiles(baseDir, candidateTarget) {
442
531
  /**
443
532
  * Computes a line-by-line diff between two text strings using LCS with
444
533
  * common prefix and suffix trimming for optimal O(delta) performance.
445
- * Returns an array of diff items: { type: 'same' | 'added' | 'removed', line: string, oldNum?: number, newNum?: number }
446
534
  */
447
535
  export function computeLineDiff(oldText = '', newText = '') {
448
536
  const oldLines = typeof oldText === 'string' ? oldText.split(/\r?\n/) : []
449
537
  const newLines = typeof newText === 'string' ? newText.split(/\r?\n/) : []
450
-
451
- const n = oldLines.length
452
- const m = newLines.length
453
-
538
+ const n = oldLines.length, m = newLines.length
454
539
  if (n === 0 && m === 0) return []
455
540
  if (oldText === newText) {
456
- return oldLines.map((line, idx) => ({
457
- type: 'same',
458
- line,
459
- oldNum: idx + 1,
460
- newNum: idx + 1,
461
- }))
541
+ return oldLines.map((line, idx) => ({ type: 'same', line, oldNum: idx + 1, newNum: idx + 1 }))
462
542
  }
463
543
 
464
- // 1. Trim common prefix
465
544
  let start = 0
466
- while (start < n && start < m && oldLines[start] === newLines[start]) {
467
- start++
468
- }
469
-
470
- // 2. Trim common suffix
471
- let oldEnd = n - 1
472
- let newEnd = m - 1
545
+ while (start < n && start < m && oldLines[start] === newLines[start]) start++
546
+ let oldEnd = n - 1, newEnd = m - 1
473
547
  while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
474
548
  oldEnd--
475
549
  newEnd--
476
550
  }
477
551
 
478
552
  const prefixItems = []
479
- for (let k = 0; k < start; k++) {
480
- prefixItems.push({ type: 'same', line: oldLines[k], oldNum: k + 1, newNum: k + 1 })
481
- }
553
+ for (let k = 0; k < start; k++) prefixItems.push({ type: 'same', line: oldLines[k], oldNum: k + 1, newNum: k + 1 })
482
554
 
483
555
  const suffixItems = []
484
556
  const suffixLen = (n - 1) - oldEnd
485
557
  for (let k = 0; k < suffixLen; k++) {
486
- const oNum = oldEnd + 1 + k + 1
487
- const nNum = newEnd + 1 + k + 1
488
- suffixItems.push({ type: 'same', line: oldLines[oldEnd + 1 + k], oldNum: oNum, newNum: nNum })
558
+ suffixItems.push({ type: 'same', line: oldLines[oldEnd + 1 + k], oldNum: oldEnd + 2 + k, newNum: newEnd + 2 + k })
489
559
  }
490
560
 
491
561
  const subOld = oldLines.slice(start, oldEnd + 1)
492
562
  const subNew = newLines.slice(start, newEnd + 1)
493
- const subN = subOld.length
494
- const subM = subNew.length
495
-
563
+ const subN = subOld.length, subM = subNew.length
496
564
  const middleItems = []
497
565
 
498
566
  if (subN === 0) {
499
- for (let j = 0; j < subM; j++) {
500
- middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
501
- }
567
+ for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
502
568
  } else if (subM === 0) {
503
- for (let i = 0; i < subN; i++) {
504
- middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
505
- }
569
+ for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
506
570
  } else if (subN > 2000 || subM > 2000) {
507
- for (let i = 0; i < subN; i++) {
508
- middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
509
- }
510
- for (let j = 0; j < subM; j++) {
511
- middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
512
- }
571
+ for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
572
+ for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
513
573
  } else {
514
574
  const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
515
575
  for (let i = 0; i < subN; i++) {
516
576
  for (let j = 0; j < subM; j++) {
517
- if (subOld[i] === subNew[j]) {
518
- dp[i + 1][j + 1] = dp[i][j] + 1
519
- } else {
520
- dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1])
521
- }
577
+ dp[i + 1][j + 1] = subOld[i] === subNew[j] ? dp[i][j] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
522
578
  }
523
579
  }
524
580
 
525
- let i = subN
526
- let j = subM
581
+ let i = subN, j = subM
527
582
  const stack = []
528
-
529
583
  while (i > 0 || j > 0) {
530
584
  if (i > 0 && j > 0 && subOld[i - 1] === subNew[j - 1]) {
531
585
  stack.push({ type: 'same', line: subOld[i - 1], oldNum: start + i, newNum: start + j })
532
- i--
533
- j--
586
+ i--; j--
534
587
  } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
535
588
  stack.push({ type: 'added', line: subNew[j - 1], newNum: start + j })
536
589
  j--
@@ -539,7 +592,6 @@ export function computeLineDiff(oldText = '', newText = '') {
539
592
  i--
540
593
  }
541
594
  }
542
-
543
595
  middleItems.push(...stack.reverse())
544
596
  }
545
597
 
package/lib/moa-runner.js CHANGED
@@ -12,7 +12,6 @@ import { executeMultiJudgePanel, buildCompositeBlockDirectives } from './moa-mul
12
12
  import { applyBudgetGuardrails } from './moa-budget.js'
13
13
  import { extractPriorTurnBaseline, pruneMultiTurnMessages } from './moa-context.js'
14
14
  import { generateMoABenchmarkReport } from './moa-report.js'
15
-
16
15
  // Re-exports for consumers & backward compatibility
17
16
  export { slotLabel, cleanAdvisoryMessages, isBroadPromptRequiringQuestions, buildQuestionSynthesisPrompt, buildCuratorSynthesisPrompt, buildSynthesisPrompt, ANTIPATTERNS_RUBRIC } from './moa-prompts.js'
18
17
  export { parseWinnerIndex, parseRecommendedAssembler, parseMoACommand, stripOrSummarizeCode, formatMoAResponse } from './moa-parser.js'
@@ -20,7 +19,6 @@ export { estimateTokenCost, summarizeMoAUsage } from './pricing.js'
20
19
  export { REFERENCE_SYSTEM_PROMPT, callWithTransientRetry, runReferencesParallel } from './moa-candidates.js'
21
20
  export { executeTestGateForCandidates, runCandidateTestGate } from './moa-test-gate.js'
22
21
  export { streamMoATurn } from './moa-stream.js'
23
-
24
22
  /**
25
23
  * Executes the full Mixture of Agents pipeline.
26
24
  */
@@ -45,6 +43,7 @@ export async function runMoAPipeline({
45
43
  force = isTestEnvironment() ? true : false,
46
44
  checkpoint = true,
47
45
  checkpointFn = null,
46
+ runId: customRunId = null,
48
47
  }) {
49
48
  const promoteOpts = {
50
49
  webServerPort,
@@ -56,16 +55,13 @@ export async function runMoAPipeline({
56
55
  }
57
56
  if (signal?.aborted) return { error: new Error('Turn aborted') }
58
57
  const startTime = Date.now()
59
-
60
58
  // 1. Resolve configurations
61
59
  const referenceModels = Array.isArray(preset?.reference_models) && preset.reference_models.length > 0
62
60
  ? preset.reference_models : [{ provider: '', model: '' }]
63
61
  const primaryJudge = preset?.aggregator?.provider && preset?.aggregator?.model
64
62
  ? preset.aggregator : { provider: '', model: '' }
65
-
66
63
  const fallbackJudges = Array.isArray(preset?.aggregator_fallbacks) ? preset.aggregator_fallbacks : []
67
64
  const judgesChain = [primaryJudge, ...fallbackJudges]
68
-
69
65
  const refTemp = typeof preset?.reference_temperature === 'number' ? preset.reference_temperature : 0.6
70
66
  const aggTemp = typeof preset?.aggregator_temperature === 'number' ? preset.aggregator_temperature : 0.4
71
67
  const maxTokens = typeof preset?.max_tokens === 'number' ? preset.max_tokens : 4096
@@ -76,8 +72,7 @@ export async function runMoAPipeline({
76
72
  const candidateRetries = 1, refTimeoutSec = typeof preset?.reference_timeout_sec === 'number' ? preset.reference_timeout_sec : 60
77
73
  const aggTimeoutSec = typeof preset?.aggregator_timeout_sec === 'number' ? preset.aggregator_timeout_sec : 180
78
74
  const isBlindEvaluation = Boolean(preset?.blind_evaluation), isPeerCritiqueEnabled = Boolean(preset?.peer_critique_enabled)
79
- const allowCandidateOverride = Boolean(preset?.allow_candidate_override), runId = crypto.randomUUID()
80
-
75
+ const allowCandidateOverride = Boolean(preset?.allow_candidate_override), runId = customRunId || null
81
76
  // Feature 6: Budget Guardrail check
82
77
  const budgetCheck = applyBudgetGuardrails({
83
78
  references: referenceModels,
@@ -109,7 +104,6 @@ export async function runMoAPipeline({
109
104
  if (budgetCheck.action === 'trim' && typeof onProgress === 'function') {
110
105
  onProgress(`✂️ *${budgetCheck.reason}*\n`)
111
106
  }
112
-
113
107
  // 2. Collect project context for refinement tasks
114
108
  const collectedCtx = await collectProjectContext(cwd, 16000)
115
109
  const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
@@ -128,19 +122,15 @@ export async function runMoAPipeline({
128
122
  skippedList: collectedCtx.skippedList,
129
123
  })
130
124
  }
131
-
132
125
  // 3. Build prompts & Feature 8 multi-turn context
133
126
  const priorTurnBaseline = preset?.multi_turn_enabled !== false ? extractPriorTurnBaseline(messages) : null
134
127
  const prunedHistory = preset?.multi_turn_enabled !== false ? pruneMultiTurnMessages(messages) : messages
135
-
136
128
  const candidateSystemPrompt = projectContext
137
129
  ? `${REFERENCE_SYSTEM_PROMPT}\n\n### Current Project Files & Context:\n${projectContext}`
138
130
  : REFERENCE_SYSTEM_PROMPT
139
-
140
131
  const askClarifyingQuestions = preset?.ask_clarifying_questions !== false
141
132
  const needsQuestions = askClarifyingQuestions && isBroadPromptRequiringQuestions(userPrompt, messages)
142
133
  const enrichedMessages = [...prunedHistory, { role: 'user', content: userPrompt }]
143
-
144
134
  // 4. Parallel fan-out to candidate models with temperature gradient & local fallback
145
135
  const referenceOutputs = await runReferencesParallel(
146
136
  effectiveReferences,
@@ -162,12 +152,10 @@ export async function runMoAPipeline({
162
152
  callLlm,
163
153
  onProgress
164
154
  )
165
-
166
155
  if (signal?.aborted) {
167
- await cleanMoaWorkspaces(cwd)
156
+ await cleanMoaWorkspaces(cwd, { runId })
168
157
  return { error: new Error('Turn aborted') }
169
158
  }
170
-
171
159
  // Fail fast if all candidates failed
172
160
  const successfulRefs = referenceOutputs.filter((r) => r.ok)
173
161
  if (successfulRefs.length === 0) {
@@ -194,7 +182,6 @@ export async function runMoAPipeline({
194
182
  durationMs: Date.now() - startTime,
195
183
  }
196
184
  }
197
-
198
185
  // 5. Extract file blocks & write candidate workspaces
199
186
  for (let i = 0; i < referenceOutputs.length; i++) {
200
187
  const ref = referenceOutputs[i]
@@ -203,24 +190,21 @@ export async function runMoAPipeline({
203
190
  ref.files = files
204
191
  if (files.length > 0) {
205
192
  ref.syntaxWarning = (verifyFileSyntax(files) || []).map((w) => `${w.file}: ${w.error}`).join('; ')
206
- await writeCandidateWorkspace(cwd, i + 1, files)
193
+ await writeCandidateWorkspace(cwd, i + 1, files, { runId })
207
194
  }
208
195
  }
209
-
210
196
  if (signal?.aborted) {
211
- await cleanMoaWorkspaces(cwd)
197
+ await cleanMoaWorkspaces(cwd, { runId })
212
198
  return { error: new Error('Turn aborted') }
213
199
  }
214
-
215
200
  // 5b. Test Execution Gate
216
201
  await executeTestGateForCandidates({
217
202
  cwd,
218
203
  referenceOutputs,
219
204
  preset,
220
- options: { testGateEnabled, testCommand, execFn },
205
+ options: { testGateEnabled, testCommand, execFn, runId },
221
206
  onProgress,
222
207
  })
223
-
224
208
  // 6. Questionnaire synthesis branch
225
209
  if (needsQuestions) {
226
210
  if (typeof onProgress === 'function') {
@@ -248,12 +232,10 @@ export async function runMoAPipeline({
248
232
  logger.warn('[dsh-moa] Questionnaire synthesis failed, proceeding with fallback questions:', err)
249
233
  questionsContent = `### Clarification of Requirements: "${userPrompt}"\n\n1. **Architecture & Scope**: Single-file deliverable or multi-module project structure?\n2. **Design & Style**: Minimalist, dark mode, or clean neutral theme?\n3. **Functional Priorities**: Core MVP or comprehensive extended implementation?\n\n*Reply with your preferences (e.g. "1, 2") or proceed with defaults.*`
250
234
  }
251
-
252
- await cleanMoaWorkspaces(cwd)
235
+ await cleanMoaWorkspaces(cwd, { runId })
253
236
  const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + qUsage.totalTokens
254
237
  const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + qUsage.costUsd).toFixed(5))
255
238
  const durationMs = Date.now() - startTime
256
-
257
239
  try {
258
240
  await recordMoaRunAsync({
259
241
  prompt: userPrompt,
@@ -271,7 +253,6 @@ export async function runMoAPipeline({
271
253
  } catch (histErr) {
272
254
  logger.warn('[dsh-moa] Failed to record questionnaire run in history:', histErr?.message || histErr)
273
255
  }
274
-
275
256
  return {
276
257
  kind: 'questions',
277
258
  content: questionsContent,
@@ -298,15 +279,15 @@ export async function runMoAPipeline({
298
279
  // 7. Fast Mode (1 candidate, bypass judge)
299
280
  if (isFastMode) {
300
281
  if (signal?.aborted) {
301
- await cleanMoaWorkspaces(cwd)
282
+ await cleanMoaWorkspaces(cwd, { runId })
302
283
  return { error: new Error('Turn aborted') }
303
284
  }
304
285
  const single = successfulRefs[0]
305
286
  let promotedFiles = []
306
287
  if (single.files?.length > 0) {
307
- promotedFiles = await promoteCandidateWorkspace(cwd, single.index, promoteOpts)
288
+ promotedFiles = await promoteCandidateWorkspace(cwd, single.index, { ...promoteOpts, runId })
308
289
  } else {
309
- await cleanMoaWorkspaces(cwd)
290
+ await cleanMoaWorkspaces(cwd, { runId })
310
291
  }
311
292
 
312
293
  const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
@@ -358,7 +339,7 @@ export async function runMoAPipeline({
358
339
  }
359
340
 
360
341
  if (signal?.aborted) {
361
- await cleanMoaWorkspaces(cwd)
342
+ await cleanMoaWorkspaces(cwd, { runId })
362
343
  return { error: new Error('Turn aborted') }
363
344
  }
364
345
 
@@ -393,7 +374,7 @@ export async function runMoAPipeline({
393
374
  if (r2Files.length > 0) {
394
375
  cand.files = r2Files
395
376
  cand.syntaxWarning = (verifyFileSyntax(r2Files) || []).map((w) => `${w.file}: ${w.error}`).join('; ')
396
- await writeCandidateWorkspace(cwd, cand.index, r2Files)
377
+ await writeCandidateWorkspace(cwd, cand.index, r2Files, { runId })
397
378
  }
398
379
  if (typeof res === 'object' && res?.usage) {
399
380
  cand.usage.inputTokens = (cand.usage.inputTokens || 0) + (res.usage.inputTokens || 0)
@@ -411,7 +392,7 @@ export async function runMoAPipeline({
411
392
  cwd,
412
393
  referenceOutputs: successfulRefs,
413
394
  preset,
414
- options: { testGateEnabled, testCommand, execFn },
395
+ options: { testGateEnabled, testCommand, execFn, runId },
415
396
  onProgress,
416
397
  })
417
398
  }
@@ -419,7 +400,7 @@ export async function runMoAPipeline({
419
400
  // 7c. Feature 2: Multi-Judge Panel & Consensus Voting
420
401
  let multiJudgeResult = null
421
402
  if (signal?.aborted) {
422
- await cleanMoaWorkspaces(cwd)
403
+ await cleanMoaWorkspaces(cwd, { runId })
423
404
  return { error: new Error('Turn aborted') }
424
405
  }
425
406
 
@@ -508,7 +489,7 @@ export async function runMoAPipeline({
508
489
  }
509
490
 
510
491
  if (signal?.aborted) {
511
- await cleanMoaWorkspaces(cwd)
492
+ await cleanMoaWorkspaces(cwd, { runId })
512
493
  return { error: new Error('Turn aborted') }
513
494
  }
514
495
 
@@ -527,15 +508,15 @@ export async function runMoAPipeline({
527
508
 
528
509
  const keepWorkspaces = allowCandidateOverride
529
510
  if (synthesizedFiles.length > 0) {
530
- await writeCandidateWorkspace(cwd, 'curator-synthesis', synthesizedFiles)
531
- promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis', { ...promoteOpts, keepMoa: keepWorkspaces })
511
+ await writeCandidateWorkspace(cwd, 'curator-synthesis', synthesizedFiles, { runId })
512
+ promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis', { ...promoteOpts, runId, keepMoa: keepWorkspaces })
532
513
  } else if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
533
- promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex, { ...promoteOpts, keepMoa: keepWorkspaces })
514
+ promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex, { ...promoteOpts, runId, keepMoa: keepWorkspaces })
534
515
  } else if (successfulRefs[0]?.files?.length > 0) {
535
516
  const fallbackIdx = successfulRefs[0].index
536
- promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx, { ...promoteOpts, keepMoa: keepWorkspaces })
517
+ promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx, { ...promoteOpts, runId, keepMoa: keepWorkspaces })
537
518
  } else if (!keepWorkspaces) {
538
- await cleanMoaWorkspaces(cwd)
519
+ await cleanMoaWorkspaces(cwd, { runId })
539
520
  }
540
521
 
541
522
  const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
package/lib/moa-stream.js CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto'
1
2
  /**
2
3
  * Zero-latency Live Streaming Generator for MoA Turns
3
4
  */
@@ -11,6 +12,7 @@ import { runMoAPipeline } from './moa-runner.js'
11
12
  */
12
13
  export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas, webServerPort, timeMachineEngine, force }, options = {}) {
13
14
  const signal = options?.signal
15
+ const runId = options?.runId || null
14
16
  if (signal?.aborted) return
15
17
 
16
18
  const internalAbortCtrl = new AbortController()
@@ -69,7 +71,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
69
71
 
70
72
  while (!done || queue.length > 0) {
71
73
  if (combinedSignal.aborted) {
72
- await cleanMoaWorkspaces(cwd)
74
+ await cleanMoaWorkspaces(cwd, runId ? { runId } : {})
73
75
  return
74
76
  }
75
77
 
@@ -96,7 +98,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
96
98
 
97
99
  const result = await pipelinePromise
98
100
  if (combinedSignal.aborted) {
99
- await cleanMoaWorkspaces(cwd)
101
+ await cleanMoaWorkspaces(cwd, runId ? { runId } : {})
100
102
  return
101
103
  }
102
104
 
@@ -126,7 +128,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
126
128
  } finally {
127
129
  internalAbortCtrl.abort()
128
130
  if (!done) {
129
- await cleanMoaWorkspaces(cwd).catch(() => {})
131
+ await cleanMoaWorkspaces(cwd, runId ? { runId } : {}).catch(() => {})
130
132
  }
131
133
  }
132
134
  }
@@ -3,32 +3,47 @@ import fs from 'node:fs/promises'
3
3
  import fsSync from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { spawn } from 'node:child_process'
6
- import { assertPathContained } from './file-workspace.js'
6
+ import { resolveCandidateFolder } from './file-workspace.js'
7
7
  import { bestEffort } from './best-effort.js'
8
8
 
9
+ const MAX_CAPTURE_BYTES = 256 * 1024
10
+
11
+ /**
12
+ * Returns a sanitized environment stripping sensitive tokens and secrets (#161).
13
+ */
14
+ export function getScrubbedEnv(overrides = {}) {
15
+ const safeVars = new Set([
16
+ 'PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'TEMP', 'TMP',
17
+ 'USER', 'LOGNAME', 'HOME', 'SHELL', 'TERM', 'NODE_ENV', 'SYSTEMROOT',
18
+ 'WINDIR', 'COMSPEC', 'PATHEXT',
19
+ ])
20
+ const scrubbed = {}
21
+ for (const [key, val] of Object.entries(process.env)) {
22
+ if (!val || key.startsWith('MOA_')) continue
23
+ if (/(API_KEY|TOKEN|SECRET|PASSWORD|PASSWD|AUTH|CREDENTIAL|PRIVATE|KEY|ACCESS)/i.test(key)) {
24
+ continue
25
+ }
26
+ if (safeVars.has(key) || key.startsWith('LC_')) {
27
+ scrubbed[key] = val
28
+ }
29
+ }
30
+ return { ...scrubbed, ...overrides }
31
+ }
32
+
9
33
  /**
10
34
  * Resolves the directory for candidate sandbox.
11
35
  */
12
- export function resolveCandidateDir(baseDir, candidateIndex) {
36
+ export function resolveCandidateDir(baseDir, candidateIndex, options = {}) {
13
37
  if (!baseDir || candidateIndex === null || candidateIndex === undefined) return null
14
- const targetStr = String(candidateIndex).trim()
15
- const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
16
- const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
17
- const moaRoot = path.join(baseDir, '.moa')
18
- return assertPathContained(moaRoot, sub)
38
+ try {
39
+ return resolveCandidateFolder(baseDir, candidateIndex, options).candidateDir
40
+ } catch {
41
+ return null
42
+ }
19
43
  }
20
44
 
21
45
  /**
22
46
  * Executes a test command in a candidate sandbox or ephemeral test staging directory.
23
- *
24
- * @param {object} params
25
- * @param {string} params.cwd - Project root workspace directory
26
- * @param {number|string} params.candidateIndex - Index of candidate (1, 2, ...)
27
- * @param {string} params.testCommand - Shell command to execute (e.g. "npm test", "node --test")
28
- * @param {number} [params.timeoutMs=15000] - Execution timeout in ms
29
- * @param {number} [params.maxOutputChars=2000] - Maximum captured output chars
30
- * @param {function} [params.execFn] - Optional mock/test runner function
31
- * @returns {Promise<object|null>} Test result object or null if skipped
32
47
  */
33
48
  export async function runCandidateTestGate({
34
49
  cwd,
@@ -37,18 +52,14 @@ export async function runCandidateTestGate({
37
52
  timeoutMs = 15000,
38
53
  maxOutputChars = 2000,
39
54
  execFn = null,
55
+ runId = null,
40
56
  }) {
41
- if (!cwd || !testCommand || typeof testCommand !== 'string') {
42
- return null
43
- }
44
-
57
+ if (!cwd || !testCommand || typeof testCommand !== 'string') return null
45
58
  const trimmedCmd = testCommand.trim()
46
59
  if (!trimmedCmd) return null
47
60
 
48
- const candidateDir = resolveCandidateDir(cwd, candidateIndex)
49
- if (!candidateDir || !fsSync.existsSync(candidateDir)) {
50
- return null
51
- }
61
+ const candidateDir = resolveCandidateDir(cwd, candidateIndex, { runId })
62
+ if (!candidateDir || !fsSync.existsSync(candidateDir)) return null
52
63
 
53
64
  const startTime = Date.now()
54
65
  let stageDir = null
@@ -66,13 +77,19 @@ export async function runCandidateTestGate({
66
77
  const rel = path.relative(cwd, src)
67
78
  if (!rel) return true
68
79
  const parts = rel.split(path.sep)
69
- if (parts[0] === 'node_modules' || parts[0] === '.git' || parts[0] === '.moa') {
70
- return false
71
- }
72
- return true
80
+ return parts[0] !== 'node_modules' && parts[0] !== '.git' && parts[0] !== '.moa'
73
81
  },
74
82
  })
75
83
 
84
+ // #162: Preserve ESM & CJS resolution by symlinking project node_modules
85
+ const baseNodeModules = path.join(cwd, 'node_modules')
86
+ const stageNodeModules = path.join(stageDir, 'node_modules')
87
+ if (fsSync.existsSync(baseNodeModules)) {
88
+ await bestEffort('symlink node_modules in test stage', () =>
89
+ fs.symlink(baseNodeModules, stageNodeModules, process.platform === 'win32' ? 'junction' : 'dir')
90
+ )
91
+ }
92
+
76
93
  await fs.cp(candidateDir, stageDir, { recursive: true })
77
94
  executionDir = stageDir
78
95
  }
@@ -98,22 +115,25 @@ export async function runCandidateTestGate({
98
115
  stderr = customRes?.stderr || ''
99
116
  } else {
100
117
  const nodePath = path.join(cwd, 'node_modules')
101
- const env = {
102
- ...process.env,
118
+ const env = getScrubbedEnv({
103
119
  MOA_CANDIDATE_INDEX: String(candidateIndex),
104
120
  MOA_CANDIDATE_DIR: candidateDir,
105
121
  MOA_BASE_DIR: cwd,
122
+ ...(runId ? { MOA_RUN_ID: String(runId) } : {}),
106
123
  NODE_PATH: process.env.NODE_PATH ? `${nodePath}:${process.env.NODE_PATH}` : nodePath,
107
- }
124
+ })
108
125
 
109
126
  const runPromise = new Promise((resolve) => {
110
127
  let child
128
+ let timeoutHandle = null
129
+ let killEscalateHandle = null
130
+
111
131
  try {
112
132
  child = spawn(resolvedCmd, {
113
133
  cwd: executionDir,
114
134
  shell: true,
135
+ detached: process.platform !== 'win32',
115
136
  env,
116
- timeout: timeoutMs,
117
137
  })
118
138
  } catch (spawnErr) {
119
139
  resolve({ exitCode: 1, stdout: '', stderr: spawnErr.message || String(spawnErr) })
@@ -122,15 +142,50 @@ export async function runCandidateTestGate({
122
142
 
123
143
  const outChunks = []
124
144
  const errChunks = []
145
+ let capturedBytes = 0
146
+
147
+ child.stdout?.on('data', (d) => {
148
+ if (capturedBytes < MAX_CAPTURE_BYTES) {
149
+ outChunks.push(d)
150
+ capturedBytes += d.length
151
+ }
152
+ })
153
+ child.stderr?.on('data', (d) => {
154
+ if (capturedBytes < MAX_CAPTURE_BYTES) {
155
+ errChunks.push(d)
156
+ capturedBytes += d.length
157
+ }
158
+ })
159
+
160
+ const killChild = (sig = 'SIGTERM') => {
161
+ if (!child || !child.pid) return
162
+ bestEffort('test-gate kill child process group', () => {
163
+ if (process.platform !== 'win32') {
164
+ process.kill(-child.pid, sig)
165
+ } else {
166
+ child.kill(sig)
167
+ }
168
+ })
169
+ }
125
170
 
126
- child.stdout?.on('data', (d) => outChunks.push(d))
127
- child.stderr?.on('data', (d) => errChunks.push(d))
171
+ if (timeoutMs > 0) {
172
+ timeoutHandle = setTimeout(() => {
173
+ killChild('SIGTERM')
174
+ killEscalateHandle = setTimeout(() => {
175
+ killChild('SIGKILL')
176
+ }, 1000)
177
+ }, timeoutMs)
178
+ }
128
179
 
129
180
  child.on('error', (err) => {
181
+ if (timeoutHandle) clearTimeout(timeoutHandle)
182
+ if (killEscalateHandle) clearTimeout(killEscalateHandle)
130
183
  resolve({ exitCode: 1, stdout: Buffer.concat(outChunks).toString('utf8'), stderr: err.message || String(err) })
131
184
  })
132
185
 
133
186
  child.on('close', (code, sig) => {
187
+ if (timeoutHandle) clearTimeout(timeoutHandle)
188
+ if (killEscalateHandle) clearTimeout(killEscalateHandle)
134
189
  const combinedErr = Buffer.concat(errChunks).toString('utf8')
135
190
  const finalErr = sig ? `${combinedErr}\nProcess terminated by signal ${sig}`.trim() : combinedErr
136
191
  resolve({
@@ -154,9 +209,7 @@ export async function runCandidateTestGate({
154
209
  : combinedOutput
155
210
 
156
211
  const passed = exitCode === 0
157
- const summary = passed
158
- ? `PASS (${durationMs}ms)`
159
- : `FAIL (code ${exitCode}, ${durationMs}ms)`
212
+ const summary = passed ? `PASS (${durationMs}ms)` : `FAIL (code ${exitCode}, ${durationMs}ms)`
160
213
 
161
214
  return {
162
215
  candidateIndex,
@@ -199,12 +252,11 @@ export async function executeTestGateForCandidates({
199
252
  }) {
200
253
  const isEnabled = Boolean(options.testGateEnabled ?? preset.test_gate_enabled)
201
254
  const testCmd = options.testCommand || preset.test_command
202
- if (!isEnabled || !testCmd || !cwd) {
203
- return []
204
- }
255
+ if (!isEnabled || !testCmd || !cwd) return []
205
256
 
206
257
  const timeoutMs = (preset.test_gate_timeout_sec || 15) * 1000
207
258
  const maxOutputChars = preset.test_gate_max_output_chars || 2000
259
+ const runId = options.runId || preset.runId
208
260
 
209
261
  if (typeof onProgress === 'function') {
210
262
  onProgress('🧪 *Test Execution Gate: Running test suite against candidate sandboxes...*\n')
@@ -221,6 +273,7 @@ export async function executeTestGateForCandidates({
221
273
  timeoutMs,
222
274
  maxOutputChars,
223
275
  execFn: options.execFn,
276
+ runId,
224
277
  })
225
278
 
226
279
  if (res) {
@@ -234,4 +287,4 @@ export async function executeTestGateForCandidates({
234
287
  }
235
288
 
236
289
  return results
237
- }
290
+ }
package/lib/routes.js CHANGED
@@ -323,7 +323,9 @@ ctx.effect(() => {
323
323
  return
324
324
  }
325
325
  const cwd = body?.cwd || process.cwd()
326
+ const runId = body?.runId
326
327
  const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, {
328
+ runId,
327
329
  keepMoa: true,
328
330
  port: ctx.webServer?.port,
329
331
  timeMachineEngine: ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
@@ -359,8 +361,9 @@ ctx.effect(() => {
359
361
  const rawFile = u.searchParams.get('file') || ''
360
362
  const file = (rawFile && isSafeRelativePath(rawFile)) ? rawFile : ''
361
363
 
362
- const fromFiles = await readCandidateFiles(cwd, from)
363
- const toFiles = await readCandidateFiles(cwd, to)
364
+ const runId = u.searchParams.get('runId') || undefined
365
+ const fromFiles = await readCandidateFiles(cwd, from, { runId })
366
+ const toFiles = await readCandidateFiles(cwd, to, { runId })
364
367
 
365
368
  // Collect all unique file paths
366
369
  const allPaths = Array.from(new Set([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.37",
3
+ "version": "0.2.38",
4
4
  "description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",