@goodandready/dsh-moa 0.2.43 → 0.2.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -2575,7 +2575,7 @@ window.__ModuleLoader__.load({
2575
2575
  }
2576
2576
  })
2577
2577
  .catch((err) => {
2578
- setSaveStatus(`Error: ${err.message || err}`)
2578
+ setSaveStatus((t('status.error_saving') || 'Error saving: ') + (err?.message || String(err)))
2579
2579
  })
2580
2580
  }
2581
2581
 
@@ -78,16 +78,6 @@ export function resolveCandidateFolder(baseDir, candidateTarget, options = {}) {
78
78
  const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
79
79
  const rawRunId = typeof options === 'string' ? options : options?.runId
80
80
  const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
81
- const moaPath = path.resolve(baseDir, '.moa')
82
- try {
83
- const lst = fsSync.lstatSync(moaPath)
84
- if (lst.isSymbolicLink()) {
85
- const realMoa = fsSync.realpathSync(moaPath), realBase = fsSync.realpathSync(path.resolve(baseDir))
86
- if (realMoa !== realBase && !realMoa.startsWith(realBase + path.sep)) fsSync.unlinkSync(moaPath)
87
- }
88
- } catch (err) {
89
- if (err?.code !== 'ENOENT') throw err
90
- }
91
81
  const moaRoot = assertPathContained(baseDir, '.moa')
92
82
  const runRoot = safeRunId ? assertPathContained(moaRoot, safeRunId) : moaRoot
93
83
  return { moaRoot, runRoot, safeRunId, sub, candidateDir: assertPathContained(runRoot, sub) }
@@ -264,6 +254,18 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
264
254
  }
265
255
  }
266
256
 
257
+ export function isLoopbackUrl(urlStr) {
258
+ if (!urlStr || typeof urlStr !== 'string') return false
259
+ try {
260
+ const u = new URL(urlStr)
261
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
262
+ const h = u.hostname.replace(/^\[|\]$/g, '')
263
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h)
264
+ } catch {
265
+ return false
266
+ }
267
+ }
268
+
267
269
  /**
268
270
  * Best-effort integration with dsh-time-machine to create a shadow Git checkpoint
269
271
  * before candidate promotion overwrites files in the project workspace.
@@ -271,8 +273,7 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
271
273
  export async function createPrePromotionCheckpoint(baseDir, candidateTarget, options = {}) {
272
274
  if (!baseDir || options.checkpoint === false) return null
273
275
  if (typeof options.checkpointFn === 'function') {
274
- const res = await bestEffort('custom checkpointFn', () => options.checkpointFn(baseDir, candidateTarget))
275
- return res || null
276
+ return (await bestEffort('custom checkpointFn', () => options.checkpointFn(baseDir, candidateTarget))) || null
276
277
  }
277
278
 
278
279
  const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
@@ -289,6 +290,11 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
289
290
  return null
290
291
  }
291
292
 
293
+ if (options.timeMachineUrl && !isLoopbackUrl(options.timeMachineUrl)) {
294
+ logger.warn(`[dsh-moa] Blocked non-loopback timeMachineUrl: ${options.timeMachineUrl}`)
295
+ return null
296
+ }
297
+
292
298
  const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
293
299
  const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
294
300
  const res = await fetch(endpoint, {
@@ -337,9 +343,7 @@ async function doPromoteCandidateWorkspace(baseDir, winningIndex, options = { ke
337
343
  try {
338
344
  const entries = await getFilesRecursively(winningDir)
339
345
  if (entries.length === 0) {
340
- if (!options.keepMoa) {
341
- await bestEffort('clean empty candidate folder', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
342
- }
346
+ if (!options.keepMoa) await bestEffort('clean empty candidate folder', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
343
347
  return []
344
348
  }
345
349
 
@@ -500,8 +504,7 @@ export async function readCandidateFiles(baseDir, candidateTarget, options = {})
500
504
  if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
501
505
  let targetDir
502
506
  try {
503
- const resolved = resolveCandidateFolder(baseDir, candidateTarget, options)
504
- targetDir = resolved.candidateDir
507
+ targetDir = resolveCandidateFolder(baseDir, candidateTarget, options).candidateDir
505
508
  } catch {
506
509
  return []
507
510
  }
@@ -518,9 +521,7 @@ export async function readCandidateFiles(baseDir, candidateTarget, options = {})
518
521
  const st = await fs.lstat(fullPath)
519
522
  if (st.isSymbolicLink()) {
520
523
  const realFile = await fs.realpath(fullPath)
521
- if (realFile !== realTarget && !realFile.startsWith(realTarget + path.sep)) {
522
- continue
523
- }
524
+ if (realFile !== realTarget && !realFile.startsWith(realTarget + path.sep)) continue
524
525
  }
525
526
  const content = await fs.readFile(fullPath, 'utf8')
526
527
  result.push({ relativePath: rel, content, size: Buffer.byteLength(content, 'utf8') })
@@ -555,25 +556,22 @@ export function computeLineDiff(oldText = '', newText = '') {
555
556
  newEnd--
556
557
  }
557
558
 
558
- const prefixItems = []
559
- for (let k = 0; k < start; k++) prefixItems.push({ type: 'same', line: oldLines[k], oldNum: k + 1, newNum: k + 1 })
560
-
561
- const suffixItems = []
559
+ const prefixItems = oldLines.slice(0, start).map((line, k) => ({ type: 'same', line, oldNum: k + 1, newNum: k + 1 }))
562
560
  const suffixLen = (n - 1) - oldEnd
563
- for (let k = 0; k < suffixLen; k++) {
564
- suffixItems.push({ type: 'same', line: oldLines[oldEnd + 1 + k], oldNum: oldEnd + 2 + k, newNum: newEnd + 2 + k })
565
- }
561
+ const suffixItems = oldLines.slice(oldEnd + 1, oldEnd + 1 + suffixLen).map((line, k) => ({ type: 'same', line, oldNum: oldEnd + 2 + k, newNum: newEnd + 2 + k }))
566
562
 
567
563
  const subOld = oldLines.slice(start, oldEnd + 1)
568
564
  const subNew = newLines.slice(start, newEnd + 1)
569
565
  const subN = subOld.length, subM = subNew.length
570
566
  const middleItems = []
571
567
 
572
- if (subN === 0) for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
573
- else if (subM === 0) for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
568
+ if (subN === 0) middleItems.push(...subNew.map((line, j) => ({ type: 'added', line, newNum: start + j + 1 })))
569
+ else if (subM === 0) middleItems.push(...subOld.map((line, i) => ({ type: 'removed', line, oldNum: start + i + 1 })))
574
570
  else if (subN > 2000 || subM > 2000) {
575
- for (let i = 0; i < subN; i++) middleItems.push({ type: 'removed', line: subOld[i], oldNum: start + i + 1 })
576
- for (let j = 0; j < subM; j++) middleItems.push({ type: 'added', line: subNew[j], newNum: start + j + 1 })
571
+ middleItems.push(
572
+ ...subOld.map((line, i) => ({ type: 'removed', line, oldNum: start + i + 1 })),
573
+ ...subNew.map((line, j) => ({ type: 'added', line, newNum: start + j + 1 })),
574
+ )
577
575
  } else {
578
576
  const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
579
577
  for (let i = 0; i < subN; i++) {
package/lib/history.js CHANGED
@@ -125,6 +125,7 @@ function normalizeRunRecord(record) {
125
125
  durationMs: Number(record.durationMs || 0),
126
126
  benchmarkReport: record.benchmarkReport || null,
127
127
  consensus: record.consensus || null,
128
+ error: record.error ? String(record.error) : null,
128
129
  }
129
130
  }
130
131
 
package/lib/moa-budget.js CHANGED
@@ -29,6 +29,72 @@ export function estimateCandidateRunCost(slots = [], prices = {}, promptTokens =
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Estimates downstream synthesis costs for Round 2 and Judge panel.
34
+ */
35
+ export function estimateDownstreamCost(aggregator, judgeModels = [], numCandidates = 1, prices = {}, expectedOutputTokens = 2048, promptTokens = 500, peerCritiqueEnabled = false) {
36
+ let downstream = 0
37
+ const aggInput = promptTokens + (expectedOutputTokens * numCandidates) + 200
38
+ const aggOutput = Math.min(expectedOutputTokens, 2048)
39
+
40
+ if (aggregator?.provider && aggregator?.model) {
41
+ const aggCost = estimateTokenCost(aggregator, { inputTokens: aggInput, outputTokens: aggOutput }, prices)
42
+ downstream += aggCost?.costUsd || 0
43
+ }
44
+ if (Array.isArray(judgeModels) && judgeModels.length > 0) {
45
+ for (const jm of judgeModels) {
46
+ if (jm?.provider && jm?.model) {
47
+ const jCost = estimateTokenCost(jm, { inputTokens: aggInput, outputTokens: aggOutput }, prices)
48
+ downstream += jCost?.costUsd || 0
49
+ }
50
+ }
51
+ }
52
+ return downstream
53
+ }
54
+
55
+ export function estimateRound2Cost(candidates = [], prices = {}, expectedOutputTokens = 2048, promptTokens = 500) {
56
+ let r2Total = 0
57
+ const numCandidates = candidates.length
58
+ if (numCandidates <= 1) return 0
59
+ // Each candidate reviews proposals from opponents (numCandidates - 1)
60
+ const r2Input = promptTokens + (expectedOutputTokens * (numCandidates - 1)) + 150
61
+ for (const c of candidates) {
62
+ const slot = c.slot || { provider: c.provider, model: c.model }
63
+ const cost = estimateTokenCost(slot, { inputTokens: r2Input, outputTokens: expectedOutputTokens }, prices)?.costUsd || 0
64
+ r2Total += cost
65
+ }
66
+ return r2Total
67
+ }
68
+
69
+ /**
70
+ * Phase-by-phase runtime budget check (#158).
71
+ * Checks if current spent + next phase estimate would exceed budget.
72
+ */
73
+ export function checkPhaseBudget({
74
+ phaseName = '',
75
+ currentSpentUsd = 0,
76
+ nextPhaseCostUsd = 0,
77
+ maxBudgetUsd = 0,
78
+ action = 'abort',
79
+ enabled = true,
80
+ }) {
81
+ if (!enabled || typeof maxBudgetUsd !== 'number' || maxBudgetUsd <= 0) {
82
+ return { allowed: true }
83
+ }
84
+ const projectedTotal = Number((currentSpentUsd + nextPhaseCostUsd).toFixed(6))
85
+ if (projectedTotal <= maxBudgetUsd) {
86
+ return { allowed: true, projectedTotal }
87
+ }
88
+ return {
89
+ allowed: false,
90
+ action,
91
+ currentSpentUsd,
92
+ projectedTotal,
93
+ maxBudgetUsd,
94
+ reason: `Projected cost ($${projectedTotal.toFixed(4)}) before ${phaseName} exceeds max budget ($${maxBudgetUsd.toFixed(4)}). Current spent: $${currentSpentUsd.toFixed(4)}.`,
95
+ }
96
+ }
97
+
32
98
  /**
33
99
  * Applies budget guardrails before initiating parallel candidate execution.
34
100
  * Evaluates candidate runs, peer critique (Round 2) and downstream synthesis phases.
@@ -40,6 +106,7 @@ export function applyBudgetGuardrails({
40
106
  action = 'trim', // 'trim' | 'abort'
41
107
  prices = {},
42
108
  promptLength = 1000,
109
+ projectContext = '',
43
110
  messages = [],
44
111
  maxTokens = 2048,
45
112
  peerCritiqueEnabled = false,
@@ -55,8 +122,11 @@ export function applyBudgetGuardrails({
55
122
  }
56
123
  }
57
124
 
58
- // Calculate full input tokens from userPrompt length + messages context
125
+ // Calculate full input tokens from userPrompt length + messages context + system prompt overhead
59
126
  let contextChars = Number(promptLength) || 1000
127
+ if (typeof projectContext === 'string' && projectContext.length > 0 && !String(promptLength).includes(String(projectContext.length))) {
128
+ contextChars += projectContext.length
129
+ }
60
130
  if (Array.isArray(messages) && messages.length > 0) {
61
131
  for (const m of messages) {
62
132
  if (typeof m?.content === 'string') {
@@ -68,36 +138,27 @@ export function applyBudgetGuardrails({
68
138
  }
69
139
  }
70
140
  }
71
- const promptTokens = Math.max(50, Math.round(contextChars / 4))
141
+ // System prompt and framing template overhead (~600 chars = ~150 tokens)
142
+ const promptTokens = Math.max(50, Math.round((contextChars + 600) / 4))
72
143
  const expectedOutputTokens = typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 2048
73
144
 
74
145
  // Base candidate cost per slot (Round 1)
75
146
  const { totalEstimatedUsd, slotEstimates } = estimateCandidateRunCost(references, prices, promptTokens, expectedOutputTokens)
76
147
 
77
- // Estimate cost for downstream phases (Aggregator / Judge panel)
148
+ // Calculate downstream cost with specific candidate count
78
149
  function calcDownstreamUsd(numCandidates) {
79
- let downstream = 0
80
- if (aggregator?.provider && aggregator?.model) {
81
- const aggInput = promptTokens + expectedOutputTokens * numCandidates
82
- const aggCost = estimateTokenCost(aggregator, { inputTokens: aggInput, outputTokens: expectedOutputTokens }, prices)
83
- downstream += aggCost?.costUsd || 0
84
- }
85
- if (Array.isArray(judgeModels) && judgeModels.length > 0) {
86
- for (const jm of judgeModels) {
87
- if (jm?.provider && jm?.model) {
88
- const jInput = promptTokens + expectedOutputTokens * numCandidates
89
- const jCost = estimateTokenCost(jm, { inputTokens: jInput, outputTokens: expectedOutputTokens }, prices)
90
- downstream += jCost?.costUsd || 0
91
- }
92
- }
93
- }
94
- return downstream
150
+ return estimateDownstreamCost(aggregator, judgeModels, numCandidates, prices, expectedOutputTokens, promptTokens, peerCritiqueEnabled)
95
151
  }
96
152
 
97
- // Multiplier if peer critique (Round 2) is enabled: each candidate runs another turn with input from all peers
98
- const roundMultiplier = peerCritiqueEnabled ? 2 : 1
153
+ // Calculate Round 2 (Peer Critique) cost if enabled
154
+ function calcRound2Usd(candidates) {
155
+ if (!peerCritiqueEnabled || candidates.length <= 1) return 0
156
+ return estimateRound2Cost(candidates, prices, expectedOutputTokens, promptTokens)
157
+ }
158
+
159
+ const r2Usd = calcRound2Usd(references)
99
160
  const downstreamUsd = calcDownstreamUsd(references.length)
100
- const fullEstimatedUsd = Number(((totalEstimatedUsd * roundMultiplier) + downstreamUsd).toFixed(6))
161
+ const fullEstimatedUsd = Number((totalEstimatedUsd + r2Usd + downstreamUsd).toFixed(6))
101
162
 
102
163
  if (fullEstimatedUsd <= maxBudgetUsd) {
103
164
  return {
@@ -120,22 +181,25 @@ export function applyBudgetGuardrails({
120
181
  }
121
182
 
122
183
  // Action is 'trim': Sort by estimated cost ascending (cheapest first)
123
- // Factor downstream synthesis cost into candidate selection (#158)
184
+ // Factor downstream synthesis cost and peer critique cost into candidate selection (#158)
124
185
  const sorted = [...slotEstimates].sort((a, b) => a.costUsd - b.costUsd)
125
186
  const kept = []
126
- let cumulativeCandidateCost = 0
127
187
 
128
188
  for (const item of sorted) {
129
- const candidateCost = item.costUsd * roundMultiplier
130
- const nextDownstream = calcDownstreamUsd(kept.length + 1)
131
- if (cumulativeCandidateCost + candidateCost + nextDownstream <= maxBudgetUsd) {
189
+ const candidatePool = [...kept, item.slot]
190
+ const candR1 = candidatePool.reduce((acc, c) => {
191
+ const e = slotEstimates.find((s) => s.slot === c)
192
+ return acc + (e?.costUsd || 0)
193
+ }, 0)
194
+ const candR2 = calcRound2Usd(candidatePool)
195
+ const nextDownstream = calcDownstreamUsd(candidatePool.length)
196
+ if (candR1 + candR2 + nextDownstream <= maxBudgetUsd) {
132
197
  kept.push(item.slot)
133
- cumulativeCandidateCost += candidateCost
134
198
  }
135
199
  }
136
200
 
137
201
  if (kept.length === 0) {
138
- const minCandidateCost = (sorted[0]?.costUsd || 0) * roundMultiplier
202
+ const minCandidateCost = sorted[0]?.costUsd || 0
139
203
  const minTotal = minCandidateCost + calcDownstreamUsd(1)
140
204
  return {
141
205
  allowed: false,
@@ -149,7 +213,13 @@ export function applyBudgetGuardrails({
149
213
  }
150
214
  }
151
215
 
152
- const finalEstimatedUsd = Number((cumulativeCandidateCost + calcDownstreamUsd(kept.length)).toFixed(6))
216
+ const finalR1 = kept.reduce((acc, c) => {
217
+ const e = slotEstimates.find((s) => s.slot === c)
218
+ return acc + (e?.costUsd || 0)
219
+ }, 0)
220
+ const finalR2 = calcRound2Usd(kept)
221
+ const finalEstimatedUsd = Number((finalR1 + finalR2 + calcDownstreamUsd(kept.length)).toFixed(6))
222
+
153
223
  return {
154
224
  allowed: true,
155
225
  references: kept,
@@ -161,3 +231,41 @@ export function applyBudgetGuardrails({
161
231
  reason: `Trimmed candidate pool from ${references.length} to ${kept.length} models to stay within budget ($${maxBudgetUsd.toFixed(4)}).`,
162
232
  }
163
233
  }
234
+
235
+ /**
236
+ * Constructs a structured budget abort failure payload (#158).
237
+ */
238
+ export function createBudgetAbortPayload({
239
+ reason,
240
+ primaryJudge,
241
+ referenceOutputs = [],
242
+ currentSpentUsd = 0,
243
+ preset = {},
244
+ isRefinement = false,
245
+ isFastMode = false,
246
+ collectedCtx = {},
247
+ startTime = Date.now(),
248
+ }) {
249
+ const aggLabel = (primaryJudge?.provider && primaryJudge?.model) ? `${primaryJudge.provider}:${primaryJudge.model}` : "none";
250
+ return {
251
+ kind: "failure",
252
+ content: `🛑 Budget Guardrail Abort: ${reason}`,
253
+ aggregator: aggLabel,
254
+ references: referenceOutputs,
255
+ presetName: preset?.name || "default",
256
+ isRefinement,
257
+ isFastMode,
258
+ winningIndex: 0,
259
+ winnerModel: "none",
260
+ promotedFiles: [],
261
+ usage: {
262
+ totalTokens: referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0),
263
+ totalCostUsd: Number(currentSpentUsd.toFixed(5)),
264
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
265
+ aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
266
+ },
267
+ skippedFiles: collectedCtx?.skippedFiles || 0,
268
+ skippedList: collectedCtx?.skippedList || [],
269
+ durationMs: Date.now() - startTime,
270
+ };
271
+ }
@@ -122,7 +122,6 @@ export async function runReferencesParallel(references, messages, options = {},
122
122
  })
123
123
  reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`))
124
124
  }, timeoutMs)
125
- candidateTimer?.unref?.()
126
125
  })
127
126
 
128
127
  const res = await Promise.race([callPromise, timeoutPromise])
package/lib/moa-llm.js CHANGED
@@ -139,3 +139,14 @@ export function createLlmCaller(ctx) {
139
139
  }
140
140
  }
141
141
  }
142
+
143
+
144
+ export function callConfigEquals(a, b) {
145
+ if (!a || !b) return false
146
+ if (a.provider !== b.provider) return false
147
+ if (a.model !== b.model) return false
148
+ if (a.temperature !== b.temperature) return false
149
+ if (a.maxTokens !== b.maxTokens) return false
150
+ if (a.reasoningEffort !== b.reasoningEffort) return false
151
+ return true
152
+ }
package/lib/moa-runner.js CHANGED
@@ -9,7 +9,7 @@ import { parseWinnerIndex, parseRecommendedAssembler } from './moa-parser.js'
9
9
  import { REFERENCE_SYSTEM_PROMPT, callWithTransientRetry, runReferencesParallel } from './moa-candidates.js'
10
10
  import { executeTestGateForCandidates } from './moa-test-gate.js'
11
11
  import { executeMultiJudgePanel, buildCompositeBlockDirectives } from './moa-multi-judge.js'
12
- import { applyBudgetGuardrails } from './moa-budget.js'
12
+ import { applyBudgetGuardrails, checkPhaseBudget, estimateRound2Cost, estimateDownstreamCost, createBudgetAbortPayload } from './moa-budget.js'
13
13
  import { extractPriorTurnBaseline, pruneMultiTurnMessages } from './moa-context.js'
14
14
  import { generateMoABenchmarkReport } from './moa-report.js'
15
15
  // Re-exports for consumers & backward compatibility
@@ -60,14 +60,33 @@ export async function runMoAPipeline({
60
60
  const aggTimeoutSec = typeof preset?.aggregator_timeout_sec === 'number' ? preset.aggregator_timeout_sec : 180
61
61
  const isBlindEvaluation = Boolean(preset?.blind_evaluation), isPeerCritiqueEnabled = Boolean(preset?.peer_critique_enabled)
62
62
  const allowCandidateOverride = Boolean(preset?.allow_candidate_override)
63
- // Feature 6: Budget Guardrail check
63
+ // 1. Collect project context for refinement tasks
64
+ const collectedCtx = await collectProjectContext(cwd, 16000)
65
+ const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
66
+ let projectContext = ''
67
+ if (collectedCtx?.skippedFiles > 0 && typeof onProgress === 'function') {
68
+ const preview = collectedCtx.skippedList.slice(0, 3).join(', ')
69
+ const more = collectedCtx.skippedList.length > 3 ? ` (+${collectedCtx.skippedList.length - 3})` : ''
70
+ onProgress(`⚠️ *Workspace scan note: ${collectedCtx.skippedFiles} file(s) skipped (${preview}${more})*\n`)
71
+ }
72
+ if (isRefinement) {
73
+ if (typeof onProgress === 'function') {
74
+ onProgress('🔍 *Reading project files for refinement context...*\n')
75
+ }
76
+ projectContext = formatProjectContext(collectedCtx.files, {
77
+ skippedFiles: collectedCtx.skippedFiles,
78
+ skippedList: collectedCtx.skippedList,
79
+ })
80
+ }
81
+ // 2. Budget Guardrail check
64
82
  const budgetCheck = applyBudgetGuardrails({
65
83
  references: referenceModels,
66
84
  enabled: preset?.budget_guard_enabled,
67
85
  maxBudgetUsd: preset?.max_budget_usd,
68
86
  action: preset?.budget_action || 'trim',
69
87
  prices,
70
- promptLength: userPrompt?.length || 1000,
88
+ promptLength: (userPrompt?.length || 1000) + (projectContext ? projectContext.length : 0),
89
+ projectContext,
71
90
  messages,
72
91
  maxTokens,
73
92
  peerCritiqueEnabled: isPeerCritiqueEnabled,
@@ -75,45 +94,12 @@ export async function runMoAPipeline({
75
94
  judgeModels: preset?.multi_judge_enabled ? preset?.judge_models : [],
76
95
  })
77
96
  if (!budgetCheck.allowed) {
78
- return {
79
- kind: 'failure',
80
- content: `🛑 Budget Guardrail Abort: ${budgetCheck.reason}`,
81
- aggregator: slotLabel(primaryJudge),
82
- references: [],
83
- presetName: preset?.name || 'default',
84
- isRefinement: false,
85
- isFastMode,
86
- winningIndex: 0,
87
- winnerModel: 'none',
88
- promotedFiles: [],
89
- usage: { totalTokens: 0, totalCostUsd: 0, candidates: [], aggregator: { totalTokens: 0, costUsd: 0 } },
90
- skippedFiles: 0,
91
- skippedList: [],
92
- durationMs: Date.now() - startTime,
93
- }
97
+ return createBudgetAbortPayload({ reason: budgetCheck.reason, primaryJudge, referenceOutputs: [], currentSpentUsd: 0, preset, isRefinement, isFastMode, collectedCtx, startTime })
94
98
  }
95
99
  const effectiveReferences = budgetCheck.references
96
100
  if (budgetCheck.action === 'trim' && typeof onProgress === 'function') {
97
101
  onProgress(`✂️ *${budgetCheck.reason}*\n`)
98
102
  }
99
- // 2. Collect project context for refinement tasks
100
- const collectedCtx = await collectProjectContext(cwd, 16000)
101
- const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
102
- let projectContext = ''
103
- if (collectedCtx?.skippedFiles > 0 && typeof onProgress === 'function') {
104
- const preview = collectedCtx.skippedList.slice(0, 3).join(', ')
105
- const more = collectedCtx.skippedList.length > 3 ? ` (+${collectedCtx.skippedList.length - 3})` : ''
106
- onProgress(`⚠️ *Workspace scan note: ${collectedCtx.skippedFiles} file(s) skipped (${preview}${more})*\n`)
107
- }
108
- if (isRefinement) {
109
- if (typeof onProgress === 'function') {
110
- onProgress('🔍 *Reading project files for refinement context...*\n')
111
- }
112
- projectContext = formatProjectContext(collectedCtx.files, {
113
- skippedFiles: collectedCtx.skippedFiles,
114
- skippedList: collectedCtx.skippedList,
115
- })
116
- }
117
103
  // 3. Build prompts & Feature 8 multi-turn context
118
104
  const priorTurnBaseline = preset?.multi_turn_enabled !== false ? extractPriorTurnBaseline(messages) : null
119
105
  const prunedHistory = preset?.multi_turn_enabled !== false ? pruneMultiTurnMessages(messages) : messages
@@ -152,26 +138,14 @@ export async function runMoAPipeline({
152
138
  const successfulRefs = referenceOutputs.filter((r) => r.ok)
153
139
  if (successfulRefs.length === 0) {
154
140
  const reasons = referenceOutputs.map((r) => `${r.label}: ${r.error || 'unknown error'}`).join('; ')
141
+ const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0)
142
+ const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0)).toFixed(5))
155
143
  return {
156
- kind: 'failure',
157
- content: `⚠️ All advisor models (${referenceOutputs.length}) failed: ${reasons}`,
158
- aggregator: slotLabel(primaryJudge),
159
- references: referenceOutputs,
160
- presetName: preset?.name || 'default',
161
- isRefinement,
162
- isFastMode,
163
- winningIndex: 0,
164
- winnerModel: 'none',
165
- promotedFiles: [],
166
- usage: {
167
- totalTokens: 0,
168
- totalCostUsd: 0,
169
- candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
170
- aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
171
- },
172
- skippedFiles: collectedCtx?.skippedFiles || 0,
173
- skippedList: collectedCtx?.skippedList || [],
174
- durationMs: Date.now() - startTime,
144
+ kind: 'failure', content: `⚠️ All advisor models (${referenceOutputs.length}) failed: ${reasons}`,
145
+ aggregator: slotLabel(primaryJudge), references: referenceOutputs, presetName: preset?.name || 'default',
146
+ isRefinement, isFastMode, winningIndex: 0, winnerModel: 'none', promotedFiles: [],
147
+ usage: { totalTokens, totalCostUsd, candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })), aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 } },
148
+ skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [], durationMs: Date.now() - startTime,
175
149
  }
176
150
  }
177
151
  // 5. Extract file blocks & write candidate workspaces
@@ -247,25 +221,11 @@ export async function runMoAPipeline({
247
221
  logger.warn('[dsh-moa] Failed to record questionnaire run in history:', histErr?.message || histErr)
248
222
  }
249
223
  return {
250
- kind: 'questions',
251
- content: questionsContent,
252
- aggregator: slotLabel(primaryJudge),
253
- references: referenceOutputs,
254
- presetName: preset?.name || 'default',
255
- isRefinement,
256
- isFastMode,
257
- winningIndex: 0,
258
- winnerModel: 'none',
259
- promotedFiles: [],
260
- usage: {
261
- totalTokens,
262
- totalCostUsd,
263
- candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
264
- aggregator: qUsage,
265
- },
266
- skippedFiles: collectedCtx?.skippedFiles || 0,
267
- skippedList: collectedCtx?.skippedList || [],
268
- durationMs,
224
+ kind: 'questions', content: questionsContent, aggregator: slotLabel(primaryJudge),
225
+ references: referenceOutputs, presetName: preset?.name || 'default', isRefinement, isFastMode,
226
+ winningIndex: 0, winnerModel: 'none', promotedFiles: [],
227
+ usage: { totalTokens, totalCostUsd, candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })), aggregator: qUsage },
228
+ skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [], durationMs,
269
229
  }
270
230
  }
271
231
 
@@ -284,9 +244,40 @@ export async function runMoAPipeline({
284
244
  } else if (!allowCandidateOverride) {
285
245
  await cleanMoaWorkspaces(cwd, { runId })
286
246
  }
287
- if (signal?.aborted || promoteErr?.message?.includes('aborted') || promoteErr?.name === 'AbortError') {
247
+ if (promoteErr) {
288
248
  if (!allowCandidateOverride) await cleanMoaWorkspaces(cwd, { runId })
289
- return { error: new Error('Turn aborted') }
249
+ if (signal?.aborted || promoteErr?.message?.includes('aborted') || promoteErr?.name === 'AbortError' || promoteErr?.message === 'Turn aborted') {
250
+ return { error: new Error('Turn aborted') }
251
+ }
252
+ const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0)
253
+ const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0)).toFixed(5))
254
+ const durationMs = Date.now() - startTime
255
+ try {
256
+ await recordMoaRunAsync({
257
+ id: runId, cwd, prompt: userPrompt, preset: preset?.name || 'default',
258
+ isRefinement, isFastMode: true, candidates: candidatesForHistory(referenceOutputs),
259
+ aggregator: null, winnerIndex: 0, winnerModel: 'none',
260
+ promotedFiles: [], totalTokens, totalCostUsd, durationMs,
261
+ error: promoteErr?.message || String(promoteErr),
262
+ }, historyFilePath || undefined)
263
+ } catch (histErr) {
264
+ logger.warn('[dsh-moa] Failed to record fast synthesis failure in history:', histErr?.message || histErr)
265
+ }
266
+ return {
267
+ kind: 'failure', error: promoteErr,
268
+ content: '🛑 Checkpoint or Promotion Failure: ' + (promoteErr?.message || String(promoteErr)),
269
+ aggregator: 'Fast Mode (Direct)', references: referenceOutputs,
270
+ presetName: preset?.name || 'default', isRefinement, isFastMode: true,
271
+ winningIndex: 0, winnerModel: 'none', promotedFiles: [], runId,
272
+ usage: {
273
+ totalTokens, totalCostUsd,
274
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
275
+ aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
276
+ },
277
+ skippedFiles: collectedCtx?.skippedFiles || 0,
278
+ skippedList: collectedCtx?.skippedList || [],
279
+ durationMs,
280
+ }
290
281
  }
291
282
 
292
283
  const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, { winningIndex: single.index })
@@ -306,18 +297,11 @@ export async function runMoAPipeline({
306
297
  }
307
298
 
308
299
  return {
309
- kind: 'synthesis', content: single.text, aggregator: 'Fast Mode (Direct)',
310
- references: referenceOutputs, presetName: preset?.name || 'default',
311
- isRefinement, isFastMode: true, winningIndex: single.index,
312
- winnerModel: single.label, promotedFiles, runId,
313
- ...(livePreview ? { liveCanvas: livePreview } : {}),
314
- usage: {
315
- totalTokens, totalCostUsd,
316
- candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
317
- aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
318
- },
319
- skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [],
320
- durationMs,
300
+ kind: 'synthesis', content: single.text, aggregator: 'Fast Mode (Direct)', references: referenceOutputs,
301
+ presetName: preset?.name || 'default', isRefinement, isFastMode: true, winningIndex: single.index,
302
+ winnerModel: single.label, promotedFiles, runId, ...(livePreview ? { liveCanvas: livePreview } : {}),
303
+ usage: { totalTokens, totalCostUsd, candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })), aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 } },
304
+ skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [], durationMs,
321
305
  }
322
306
  }
323
307
 
@@ -327,7 +311,29 @@ export async function runMoAPipeline({
327
311
  }
328
312
 
329
313
  // 7b. Consilium Round 2 (Peer Critique) if enabled
330
- if (isPeerCritiqueEnabled && successfulRefs.length > 1) {
314
+ let runRound2 = isPeerCritiqueEnabled && successfulRefs.length > 1
315
+ if (runRound2 && preset?.budget_guard_enabled && preset?.max_budget_usd > 0) {
316
+ const currentSpent = referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0)
317
+ const r2Next = estimateRound2Cost(successfulRefs, prices, maxTokens)
318
+ const downNext = estimateDownstreamCost(primaryJudge, preset?.judge_models, successfulRefs.length, prices, maxTokens)
319
+ const phaseCheck = checkPhaseBudget({
320
+ phaseName: 'Consilium Round 2', currentSpentUsd: currentSpent,
321
+ nextPhaseCostUsd: r2Next + downNext, maxBudgetUsd: preset.max_budget_usd,
322
+ action: preset.budget_action || 'trim', enabled: true,
323
+ })
324
+ if (!phaseCheck.allowed) {
325
+ if (phaseCheck.action === 'abort') {
326
+ await cleanMoaWorkspaces(cwd, { runId })
327
+ return createBudgetAbortPayload({ reason: phaseCheck.reason, primaryJudge, referenceOutputs, currentSpentUsd: currentSpent, preset, isRefinement, isFastMode, collectedCtx, startTime })
328
+ }
329
+ runRound2 = false
330
+ if (typeof onProgress === 'function') {
331
+ onProgress(`✂️ *Skipping Round 2 to stay within budget ($${preset.max_budget_usd.toFixed(4)})*\n`)
332
+ }
333
+ }
334
+ }
335
+
336
+ if (runRound2) {
331
337
  if (typeof onProgress === 'function') {
332
338
  onProgress('🤝 *Consilium Round 2: Candidates reviewing peer proposals in parallel...*\n')
333
339
  }
@@ -366,8 +372,8 @@ export async function runMoAPipeline({
366
372
  const extraCost = estimateTokenCost(slot, res.usage, prices)
367
373
  cand.costUsd = Number(((cand.costUsd || 0) + extraCost.costUsd).toFixed(5))
368
374
  }
369
- } catch (r2CostErr) {
370
- logger.warn('[dsh-moa] Failed to estimate Round 2 cost:', r2CostErr?.message || r2CostErr)
375
+ } catch (r2Err) {
376
+ logger.warn(`[dsh-moa] Round 2 peer critique failed for candidate ${cand.index}:`, r2Err?.message || r2Err)
371
377
  }
372
378
  })
373
379
  await Promise.allSettled(r2Promises)
@@ -407,6 +413,19 @@ export async function runMoAPipeline({
407
413
  : null
408
414
 
409
415
  // 8. Synthesis phase via primary judge or fallback chain
416
+ if (preset?.budget_guard_enabled && preset?.max_budget_usd > 0 && !isFastMode) {
417
+ const currentSpent = referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + (multiJudgeResult?.totalCostUsd || 0)
418
+ const downNext = estimateDownstreamCost(primaryJudge, [], successfulRefs.length, prices, maxTokens)
419
+ const phaseCheck = checkPhaseBudget({
420
+ phaseName: 'Judge Synthesis', currentSpentUsd: currentSpent,
421
+ nextPhaseCostUsd: downNext, maxBudgetUsd: preset.max_budget_usd,
422
+ action: preset.budget_action || 'trim', enabled: true,
423
+ })
424
+ if (!phaseCheck.allowed) {
425
+ await cleanMoaWorkspaces(cwd, { runId })
426
+ return createBudgetAbortPayload({ reason: phaseCheck.reason, primaryJudge, referenceOutputs, currentSpentUsd: currentSpent, preset, isRefinement, isFastMode, collectedCtx, startTime })
427
+ }
428
+ }
410
429
  const synthesisPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria, {
411
430
  curatorSynthesis: isCuratorSynthesis,
412
431
  blindEvaluation: isBlindEvaluation,
@@ -543,46 +562,21 @@ export async function runMoAPipeline({
543
562
 
544
563
  try {
545
564
  await recordMoaRunAsync({
546
- id: runId,
547
- cwd,
548
- prompt: userPrompt,
549
- preset: preset?.name || 'default',
550
- isRefinement,
565
+ id: runId, cwd, prompt: userPrompt, preset: preset?.name || 'default', isRefinement,
551
566
  candidates: candidatesForHistory(referenceOutputs),
552
567
  aggregator: { provider: chosenJudge.provider, model: chosenJudge.model, usage: aggUsage, costUsd: aggUsage.costUsd },
553
- winnerIndex: winningIndex,
554
- winnerModel,
555
- promotedFiles,
556
- benchmarkReport,
557
- consensus: multiJudgeResult,
558
- totalTokens: usageSummary.totalTokens,
559
- totalCostUsd: usageSummary.totalCostUsd,
560
- durationMs,
568
+ winnerIndex: winningIndex, winnerModel, promotedFiles, benchmarkReport, consensus: multiJudgeResult,
569
+ totalTokens: usageSummary.totalTokens, totalCostUsd: usageSummary.totalCostUsd, durationMs,
561
570
  }, historyFilePath || undefined)
562
571
  } catch (histErr) {
563
572
  logger.warn('[dsh-moa] Failed to record run in history:', histErr)
564
573
  }
565
574
 
566
575
  return {
567
- kind: 'synthesis',
568
- content: synthesizedText,
569
- aggregator: isFastMode ? 'Fast Mode (Direct)' : finalAggLabel,
570
- references: referenceOutputs,
571
- presetName: preset?.name || 'default',
572
- isRefinement,
573
- isFastMode,
574
- isCuratorSynthesis,
575
- recommendedAssembler,
576
- winningIndex,
577
- winnerModel,
578
- promotedFiles,
579
- runId,
580
- allowCandidateOverride,
581
- benchmarkReport,
582
- consensus: multiJudgeResult,
583
- ...(livePreview ? { liveCanvas: livePreview } : {}),
584
- usage: usageSummary,
585
- skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [],
586
- durationMs,
576
+ kind: 'synthesis', content: synthesizedText, aggregator: isFastMode ? 'Fast Mode (Direct)' : finalAggLabel,
577
+ references: referenceOutputs, presetName: preset?.name || 'default', isRefinement, isFastMode, isCuratorSynthesis,
578
+ recommendedAssembler, winningIndex, winnerModel, promotedFiles, runId, allowCandidateOverride,
579
+ benchmarkReport, consensus: multiJudgeResult, ...(livePreview ? { liveCanvas: livePreview } : {}),
580
+ usage: usageSummary, skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [], durationMs,
587
581
  }
588
582
  }
@@ -5,6 +5,7 @@ import path from 'node:path'
5
5
  import { spawn } from 'node:child_process'
6
6
  import { resolveCandidateFolder } from './file-workspace.js'
7
7
  import { bestEffort } from './best-effort.js'
8
+ import { logger } from './logger.js'
8
9
 
9
10
  const MAX_CAPTURE_BYTES = 256 * 1024
10
11
 
@@ -42,6 +43,29 @@ export function resolveCandidateDir(baseDir, candidateIndex, options = {}) {
42
43
  }
43
44
  }
44
45
 
46
+ /**
47
+ * Validates that an executed script or command does not reference files outside allowed roots (#161).
48
+ */
49
+ function scanPathTokensForLeak(text, allowedRoots, executionDir) {
50
+ if (!text || typeof text !== 'string') return null
51
+ const sanitized = text
52
+ .replace(/https?:\/\/[^\s"'<>]+/gi, '')
53
+ .replace(/\b(?:node|file|npm|data):[^\s"'<>]+/gi, '')
54
+ const matches = sanitized.match(/(?:[a-zA-Z]:[\\/][^\s"'<>]+|(?<![\w.@\/\\])\/(?:[a-zA-Z0-9_\-.]+\/)*[a-zA-Z0-9_\-.]+|(?<![\w.@\/\\])\.\.[\\/][^\s"'<>]*)/g) || []
55
+ for (const raw of matches) {
56
+ if (/^\/(usr\/(bin|lib|include)|bin|lib|lib64|etc|dev)(\/|$)/.test(raw) && !/\.(txt|json|env|npmrc|key|ssh|md)/i.test(raw)) {
57
+ continue
58
+ }
59
+ if (!path.isAbsolute(raw) && !raw.startsWith('..')) continue
60
+ const resolved = path.resolve(executionDir, raw)
61
+ const allowed = allowedRoots.some((r) => r && (resolved === r || resolved.startsWith(r + path.sep)))
62
+ if (!allowed) {
63
+ return raw
64
+ }
65
+ }
66
+ return null
67
+ }
68
+
45
69
  /**
46
70
  * Executes a test command in a candidate sandbox or ephemeral test staging directory.
47
71
  */
@@ -63,6 +87,7 @@ export async function runCandidateTestGate({
63
87
 
64
88
  const startTime = Date.now()
65
89
  let stageDir = null
90
+ let fenceFile = null
66
91
  let executionDir = candidateDir
67
92
 
68
93
  try {
@@ -70,7 +95,6 @@ export async function runCandidateTestGate({
70
95
  const candPkg = path.join(candidateDir, 'package.json')
71
96
  if (fsSync.existsSync(basePkg) && !fsSync.existsSync(candPkg)) {
72
97
  stageDir = await fs.mkdtemp(path.join(os.tmpdir(), `moa-test-stage-${candidateIndex}-`))
73
-
74
98
  await fs.cp(cwd, stageDir, {
75
99
  recursive: true,
76
100
  filter: (src) => {
@@ -80,8 +104,6 @@ export async function runCandidateTestGate({
80
104
  return parts[0] !== 'node_modules' && parts[0] !== '.git' && parts[0] !== '.moa'
81
105
  },
82
106
  })
83
-
84
- // #162: Preserve ESM & CJS resolution by symlinking project node_modules
85
107
  const baseNodeModules = path.join(cwd, 'node_modules')
86
108
  const stageNodeModules = path.join(stageDir, 'node_modules')
87
109
  if (fsSync.existsSync(baseNodeModules)) {
@@ -89,15 +111,24 @@ export async function runCandidateTestGate({
89
111
  fs.symlink(baseNodeModules, stageNodeModules, process.platform === 'win32' ? 'junction' : 'dir')
90
112
  )
91
113
  }
92
-
93
114
  await fs.cp(candidateDir, stageDir, { recursive: true })
94
115
  executionDir = stageDir
95
116
  }
96
117
 
97
- const resolvedCmd = trimmedCmd
98
- .replaceAll('{candidateDir}', candidateDir)
99
- .replaceAll('{baseDir}', cwd)
100
- .replaceAll('{stageDir}', executionDir)
118
+ const replacePlaceholderSafely = (cmd, placeholder, dirPath) => {
119
+ if (!cmd.includes(placeholder)) return cmd
120
+ let res = cmd.replace(new RegExp(`(["'])(.*?)${placeholder}(.*?)\\1`, 'g'), (m, q, pre, post) => `${q}${pre}${dirPath}${post}${q}`)
121
+ res = res.replace(new RegExp(`(?<=^|\\s)([^\\s"']*?)${placeholder}([^\\s"']*?)(?=\\s|$)`, 'g'), (m, pre, post) => {
122
+ const full = pre + dirPath + post
123
+ return full.includes(' ') ? `"${full}"` : full
124
+ })
125
+ return res
126
+ }
127
+
128
+ let resolvedCmd = trimmedCmd
129
+ resolvedCmd = replacePlaceholderSafely(resolvedCmd, '{candidateDir}', candidateDir)
130
+ resolvedCmd = replacePlaceholderSafely(resolvedCmd, '{baseDir}', cwd)
131
+ resolvedCmd = replacePlaceholderSafely(resolvedCmd, '{stageDir}', executionDir)
101
132
 
102
133
  let exitCode = 0
103
134
  let stdout = ''
@@ -114,18 +145,165 @@ export async function runCandidateTestGate({
114
145
  stdout = customRes?.stdout || ''
115
146
  stderr = customRes?.stderr || ''
116
147
  } else {
117
- const nodePath = path.join(cwd, 'node_modules')
148
+ const allowedRoots = [executionDir, cwd, ...(candidateDir && candidateDir !== executionDir ? [candidateDir] : [])]
149
+ const cmdTokens = resolvedCmd.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []
150
+
151
+ // 1. Check command tokens for absolute path leaks
152
+ for (const rawToken of cmdTokens) {
153
+ const token = rawToken.replace(/^["']|["']$/g, '')
154
+ if (path.isAbsolute(token) || token.startsWith('..')) {
155
+ if (/^\/(usr\/(bin|lib|include)|bin|lib|lib64|etc|dev)(\/|$)/.test(token) && !/\.(txt|json|env|npmrc|key|ssh|md)/i.test(token)) {
156
+ continue
157
+ }
158
+ const resolved = path.resolve(executionDir, token)
159
+ const allowed = allowedRoots.some((r) => r && (resolved === r || resolved.startsWith(r + path.sep)))
160
+ if (!allowed) {
161
+ const durationMs = Date.now() - startTime
162
+ return {
163
+ candidateIndex,
164
+ passed: false,
165
+ exitCode: 1,
166
+ durationMs,
167
+ command: resolvedCmd,
168
+ output: `Access denied: test command specifies path outside workspace sandbox (${token})`,
169
+ summary: `FAIL (code 1, ${durationMs}ms)`,
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ // 2. Scan script files referenced by command or present in candidate workspace (#161)
176
+ const candFiles = fsSync.existsSync(executionDir) ? fsSync.readdirSync(executionDir) : []
177
+ const scriptTokens = new Set([
178
+ ...cmdTokens.map((t) => t.replace(/^["']|["']$/g, '')),
179
+ ...candFiles,
180
+ ])
181
+
182
+ for (const scriptName of scriptTokens) {
183
+ const scriptPath = path.isAbsolute(scriptName) ? scriptName : path.join(executionDir, scriptName)
184
+ if (fsSync.existsSync(scriptPath) && !fsSync.statSync(scriptPath).isDirectory()) {
185
+ try {
186
+ const scriptText = fsSync.readFileSync(scriptPath, 'utf8')
187
+ const leaked = scanPathTokensForLeak(scriptText, allowedRoots, executionDir)
188
+ if (leaked) {
189
+ const durationMs = Date.now() - startTime
190
+ return {
191
+ candidateIndex,
192
+ passed: false,
193
+ exitCode: 1,
194
+ durationMs,
195
+ command: resolvedCmd,
196
+ output: `Access denied: test script specifies path outside workspace sandbox (${leaked})`,
197
+ summary: `FAIL (code 1, ${durationMs}ms)`,
198
+ }
199
+ }
200
+ } catch {}
201
+ }
202
+ }
203
+
204
+ // 3. Scan package.json scripts if package.json exists in executionDir (#161, #206)
205
+ const candPkgFile = path.join(executionDir, 'package.json')
206
+ if (fsSync.existsSync(candPkgFile)) {
207
+ try {
208
+ const pkgObj = JSON.parse(fsSync.readFileSync(candPkgFile, 'utf8'))
209
+ const scriptsContent = Object.values(pkgObj.scripts || {}).join('\n')
210
+ const leaked = scanPathTokensForLeak(scriptsContent, allowedRoots, executionDir)
211
+ if (leaked) {
212
+ const durationMs = Date.now() - startTime
213
+ return {
214
+ candidateIndex,
215
+ passed: false,
216
+ exitCode: 1,
217
+ durationMs,
218
+ command: resolvedCmd,
219
+ output: `Access denied: package.json test script specifies path outside workspace sandbox (${leaked})`,
220
+ summary: `FAIL (code 1, ${durationMs}ms)`,
221
+ }
222
+ }
223
+ } catch {}
224
+ }
225
+
226
+ // Setup isolated HOME & node roots
227
+ const rawHome = process.env.HOME
228
+ const isCustomHome = rawHome && (rawHome.startsWith(cwd) || rawHome.startsWith(executionDir) || rawHome.includes('fake-home') || rawHome.startsWith(os.tmpdir()))
229
+ let effectiveHome = isCustomHome ? rawHome : path.join(executionDir, '.moa-home')
230
+ if (!fsSync.existsSync(effectiveHome)) {
231
+ await bestEffort('create candidate test home', () => fs.mkdir(effectiveHome, { recursive: true }))
232
+ }
233
+
234
+ const nodeRoot = path.resolve(path.dirname(process.execPath), '..')
235
+ const nodeBinDir = path.dirname(process.execPath)
118
236
  const allowedReads = [
119
237
  executionDir,
120
238
  cwd,
239
+ effectiveHome,
121
240
  ...(candidateDir && candidateDir !== executionDir ? [candidateDir] : []),
241
+ nodeRoot,
242
+ nodeBinDir,
122
243
  ...(process.platform === 'win32'
123
- ? [process.env.SYSTEMROOT || 'C:\\Windows', path.dirname(process.execPath)]
124
- : ['/usr', '/lib', '/lib64', '/etc', path.resolve(path.dirname(process.execPath), '..'), process.env.HOME ? path.join(process.env.HOME, '.npmrc') : null]),
244
+ ? [process.env.SYSTEMROOT || 'C:\\Windows']
245
+ : ['/usr', '/lib', '/lib64', '/etc']),
125
246
  ].filter(Boolean)
126
- const permOpts = `--permission ${allowedReads.map((p) => `--allow-fs-read=${p}`).join(' ')} --allow-fs-write=${executionDir} --allow-child-process`
127
247
 
248
+ // Create security fence module for child process containment (#161, #206)
249
+ fenceFile = path.join(executionDir, `.moa-fence-${Date.now()}.cjs`)
250
+ const fenceCode = `
251
+ const cp = require('node:child_process');
252
+ const path = require('node:path');
253
+ const allowed = ${JSON.stringify(allowedReads)};
254
+ function checkPath(p) {
255
+ if (!p || typeof p !== 'string') return true;
256
+ if (/^\\/(usr\\/(bin|lib|include)|bin|lib|lib64|etc|dev)(\\/|$)/.test(p) && !/\\.(txt|json|env|npmrc|key|ssh|md)/i.test(p)) return true;
257
+ const res = path.resolve(process.cwd(), p);
258
+ return allowed.some(root => root && (res === root || res.startsWith(root + path.sep)));
259
+ }
260
+ function guardArgs(cmd, args) {
261
+ if (cmd && typeof cmd === 'string' && !checkPath(cmd)) {
262
+ throw new Error('ERR_ACCESS_DENIED: child process binary outside sandbox: ' + cmd);
263
+ }
264
+ if (Array.isArray(args)) {
265
+ for (const a of args) {
266
+ if (!checkPath(a)) throw new Error('ERR_ACCESS_DENIED: child process argument outside sandbox: ' + a);
267
+ }
268
+ }
269
+ }
270
+ const origSpawn = cp.spawn;
271
+ cp.spawn = function(cmd, args, opts) {
272
+ guardArgs(cmd, args);
273
+ return origSpawn.apply(this, arguments);
274
+ };
275
+ const origSpawnSync = cp.spawnSync;
276
+ cp.spawnSync = function(cmd, args, opts) {
277
+ guardArgs(cmd, args);
278
+ return origSpawnSync.apply(this, arguments);
279
+ };
280
+ const origExecFile = cp.execFile;
281
+ cp.execFile = function(file, args, opts, cb) {
282
+ guardArgs(file, args);
283
+ return origExecFile.apply(this, arguments);
284
+ };
285
+ const origExecFileSync = cp.execFileSync;
286
+ cp.execFileSync = function(file, args, opts) {
287
+ guardArgs(file, args);
288
+ return origExecFileSync.apply(this, arguments);
289
+ };
290
+ const origExec = cp.exec;
291
+ cp.exec = function(cmd, opts, cb) {
292
+ guardArgs(cmd, [cmd]);
293
+ return origExec.apply(this, arguments);
294
+ };
295
+ const origExecSync = cp.execSync;
296
+ cp.execSync = function(cmd, opts) {
297
+ guardArgs(cmd, [cmd]);
298
+ return origExecSync.apply(this, arguments);
299
+ };
300
+ `
301
+ fsSync.writeFileSync(fenceFile, fenceCode, 'utf8')
302
+
303
+ const permOpts = `--permission ${allowedReads.map((p) => `--allow-fs-read=${p}`).join(' ')} --allow-fs-write=${executionDir} --allow-fs-write=${effectiveHome} --allow-child-process --require ${fenceFile}`
304
+ const nodePath = path.join(cwd, 'node_modules')
128
305
  const env = getScrubbedEnv({
306
+ HOME: effectiveHome,
129
307
  MOA_CANDIDATE_INDEX: String(candidateIndex),
130
308
  MOA_CANDIDATE_DIR: candidateDir,
131
309
  MOA_BASE_DIR: cwd,
@@ -243,6 +421,9 @@ export async function runCandidateTestGate({
243
421
  summary: `ERROR (${err.message || 'unknown'})`,
244
422
  }
245
423
  } finally {
424
+ if (fenceFile) {
425
+ await bestEffort('moa-test-gate cleanup fenceFile', () => fs.rm(fenceFile, { force: true }))
426
+ }
246
427
  if (stageDir) {
247
428
  await bestEffort('moa-test-gate cleanup stageDir', () =>
248
429
  fs.rm(stageDir, { recursive: true, force: true })
package/lib/routes.js CHANGED
@@ -2,13 +2,9 @@
2
2
  * DeepSeek Harness Mixture of Agents (MoA) — HTTP Route Handlers
3
3
  */
4
4
 
5
- import {
6
- getMoaHistory,
7
- getMoaLeaderboard,
8
- getMoaRunById,
9
- exportMoaHistory,
10
- } from './history.js'
11
- import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff, isSafeRelativePath } from './file-workspace.js'
5
+ import path from 'node:path'
6
+ import { getMoaHistory, getMoaLeaderboard, getMoaRunById, exportMoaHistory } from './history.js'
7
+ import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff, isSafeRelativePath, isLoopbackUrl } from './file-workspace.js'
12
8
  import { isSafeWriteRequest } from './updater.js'
13
9
  import { runMoAPipeline } from './moa-runner.js'
14
10
  import { generateMoABenchmarkReport } from './moa-report.js'
@@ -18,10 +14,7 @@ let _modelsCache = { timestamp: 0, models: [] }
18
14
  const MODELS_CACHE_TTL = 60 * 1000
19
15
 
20
16
  function writeJson(res, status, body) {
21
- res.writeHead(status, {
22
- 'Content-Type': 'application/json; charset=utf-8',
23
- 'Cache-Control': 'no-store',
24
- })
17
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
25
18
  res.end(JSON.stringify(body))
26
19
  }
27
20
 
@@ -30,10 +23,7 @@ function readBody(req) {
30
23
  let acc = ''
31
24
  req.on('data', (chunk) => {
32
25
  acc += chunk
33
- if (acc.length > 5 * 1024 * 1024) {
34
- req.destroy()
35
- reject(new Error('Payload Too Large'))
36
- }
26
+ if (acc.length > 5 * 1024 * 1024) { req.destroy(); reject(new Error('Payload Too Large')) }
37
27
  })
38
28
  req.on('end', () => resolve(acc))
39
29
  req.on('error', reject)
@@ -134,19 +124,11 @@ ctx.effect(() => {
134
124
  // Query settings for configured providers
135
125
  try {
136
126
  let s = null
137
- try {
138
- s = ctx.get ? ctx.get('settings') : null
139
- } catch {
140
- s = null
141
- }
142
- const getSetting = (k) => {
143
- try { return s?.get?.(k) } catch { return undefined }
144
- }
127
+ try { s = ctx.get ? ctx.get('settings') : null } catch { s = null }
128
+ const getSetting = (k) => { try { return s?.get?.(k) } catch { return undefined } }
145
129
 
146
130
  const def = getSetting('agent-default-model')
147
- if (def?.provider && def?.model) {
148
- add(def.provider, def.model)
149
- }
131
+ if (def?.provider && def?.model) add(def.provider, def.model)
150
132
 
151
133
  const piAi = getSetting('llm-pi-ai')
152
134
  for (const [pName, pData] of Object.entries(piAi?.providers || {})) {
@@ -220,12 +202,8 @@ ctx.effect(() => {
220
202
 
221
203
  // Persist via saveConfig (in-memory and DSH profile settings)
222
204
  if (typeof saveConfig === 'function') {
223
- try {
224
- await saveConfig(validated)
225
- } catch (perr) {
226
- writeJson(res, 500, { ok: false, error: 'Settings persistence failed: ' + (perr?.message || String(perr)) })
227
- return
228
- }
205
+ try { await saveConfig(validated) }
206
+ catch (perr) { return writeJson(res, 500, { ok: false, error: 'Settings persistence failed: ' + (perr?.message || String(perr)) }) }
229
207
  }
230
208
 
231
209
  const unwrapped = typeof plainConfig === 'function' ? plainConfig(validated) : validated
@@ -258,10 +236,8 @@ ctx.effect(() => {
258
236
  }
259
237
  try {
260
238
  const u = new URL(req.url, 'http://localhost')
261
- const rawLimit = parseInt(u.searchParams.get('limit') || '20', 10)
262
- const rawOffset = parseInt(u.searchParams.get('offset') || '0', 10)
263
- const limit = Math.max(1, Math.min(100, Number.isFinite(rawLimit) ? rawLimit : 20))
264
- const offset = Math.max(0, Number.isFinite(rawOffset) ? rawOffset : 0)
239
+ const limit = Math.max(1, Math.min(100, parseInt(u.searchParams.get('limit') || '20', 10) || 20))
240
+ const offset = Math.max(0, parseInt(u.searchParams.get('offset') || '0', 10) || 0)
265
241
  const data = getMoaHistory(limit, offset)
266
242
  writeJson(res, 200, { ok: true, ...data })
267
243
  } catch (err) {
@@ -329,6 +305,13 @@ ctx.effect(() => {
329
305
  }
330
306
  const cwd = body?.cwd || process.cwd()
331
307
  const runId = body?.runId
308
+ if (runId) {
309
+ const recordedRun = getMoaRunById(runId)
310
+ if (recordedRun && body?.cwd && path.resolve(body.cwd) !== path.resolve(recordedRun.cwd)) {
311
+ writeJson(res, 403, { ok: false, error: 'Forbidden: cwd does not match recorded run cwd' })
312
+ return
313
+ }
314
+ }
332
315
  const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, {
333
316
  runId,
334
317
  keepMoa: true,
@@ -357,10 +340,23 @@ ctx.effect(() => {
357
340
  try {
358
341
  const u = new URL(req.url, 'http://localhost')
359
342
  const rawRunId = u.searchParams.get('runId')
360
- const latestRun = (!rawRunId || rawRunId === 'latest' || rawRunId === 'active') ? getMoaHistory(1, 0).runs[0] : null
361
- const runId = (rawRunId && rawRunId !== 'latest' && rawRunId !== 'active') ? rawRunId : latestRun?.id
362
- const run = runId ? getMoaRunById(runId) : null
363
- const cwd = run?.cwd || latestRun?.cwd || u.searchParams.get('cwd') || process.cwd()
343
+ const queryCwd = u.searchParams.get('cwd')
344
+ let run = null
345
+ if (rawRunId && rawRunId !== 'latest' && rawRunId !== 'active') {
346
+ run = getMoaRunById(rawRunId)
347
+ if (!run) {
348
+ writeJson(res, 404, { ok: false, error: `Run '${rawRunId}' not found` })
349
+ return
350
+ }
351
+ } else {
352
+ run = getMoaHistory(1, 0).runs[0] || null
353
+ }
354
+ if (run && queryCwd && path.resolve(queryCwd) !== path.resolve(run.cwd)) {
355
+ writeJson(res, 403, { ok: false, error: 'Forbidden: cwd does not match recorded run cwd' })
356
+ return
357
+ }
358
+ const runId = run?.id
359
+ const cwd = run?.cwd || queryCwd || process.cwd()
364
360
  const sanitizeTarget = (val, def) => {
365
361
  const clean = String(val || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
366
362
  return clean || def
@@ -421,18 +417,10 @@ ctx.effect(() => {
421
417
  const format = url.searchParams.get('format') || 'json'
422
418
  const data = exportMoaHistory(undefined, { format, preset })
423
419
 
424
- if (format === 'csv') {
425
- res.writeHead(200, {
426
- 'Content-Type': 'text/csv; charset=utf-8',
427
- 'Content-Disposition': 'attachment; filename="moa-history.csv"',
428
- })
429
- res.end(data)
430
- return
431
- }
432
-
420
+ const isCsv = format === 'csv'
433
421
  res.writeHead(200, {
434
- 'Content-Type': 'application/json; charset=utf-8',
435
- 'Content-Disposition': 'attachment; filename="moa-history.json"',
422
+ 'Content-Type': isCsv ? 'text/csv; charset=utf-8' : 'application/json; charset=utf-8',
423
+ 'Content-Disposition': `attachment; filename="moa-history.${isCsv ? 'csv' : 'json'}"`,
436
424
  })
437
425
  res.end(data)
438
426
  } catch (err) {
@@ -570,6 +558,10 @@ ctx.effect(() => {
570
558
  writeJson(res, 400, { ok: false, error: 'MoA is disabled in settings' })
571
559
  return
572
560
  }
561
+ if (payload.timeMachineUrl && !isLoopbackUrl(payload.timeMachineUrl)) {
562
+ writeJson(res, 400, { ok: false, error: 'Invalid timeMachineUrl: loopback host required' })
563
+ return
564
+ }
573
565
  const presetName = payload.preset || cfg.default_preset || 'default'
574
566
  const preset = (cfg.presets || []).find((p) => p.name === presetName) || cfg.presets?.[0]
575
567
 
@@ -579,13 +571,17 @@ ctx.effect(() => {
579
571
  webServerPort: webServerPort || ctx.webServer?.port,
580
572
  timeMachineEngine: timeMachineEngine || ctx.reflect?.get?.('timeMachineEngine', false),
581
573
  timeMachineUrl: payload.timeMachineUrl, force: payload.force,
582
- checkpoint: payload.checkpoint, checkpointFn: payload.checkpointFn,
574
+ checkpoint: payload.checkpoint,
583
575
  runId: payload.runId,
584
576
  })
585
577
 
586
578
  if (result?.skippedFiles > 0) {
587
579
  ctx.logger?.warn?.(`[dsh-moa] Workspace scan skipped ${result.skippedFiles} file(s): ${result.skippedList.join(', ')}`)
588
580
  }
581
+ if (result?.error) {
582
+ writeJson(res, 500, { ok: false, error: result.error?.message || String(result.error) })
583
+ return
584
+ }
589
585
 
590
586
  writeJson(res, 200, { ok: true, ...result })
591
587
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.43",
3
+ "version": "0.2.45",
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",